Compare commits

..
Author SHA1 Message Date
Johann Hoffmann 90a3de0705 Merge branch 'feature-77588/AnwesenheitenSprint252' into demo 2026-10-08 16:25:30 +02:00
Johann Hoffmann 57d37e01a3 only make those anw_user_entries editable which come from LE's the user is allowed to change by isAdminOrTeachesLE function; also send the correct le_id of the different le_ids of detail component to backend, not the selected one of main view; simplify the entschuldigungsmanagement filter options and only have the "alle offenen Entschuldigungen unabhängig vom gewählten Zeitraum" button available which comes with a comprehensive tooltip in simple language what that button actually does since it can be confusing to interpret which timespan and which dataset is being delivered here. 💯💥 2026-10-08 16:22:28 +02:00
Johann Hoffmann 48c1787af9 Merge branch 'feature-77588/AnwesenheitenSprint252' into demo 2026-10-07 19:12:59 +02:00
Johann Hoffmann 0428c27bef fix delete dialog handling; new multiselect mode is now hidden behind a "Gesamtansicht" Button Switch; in Gesamtansicht remove Kontrolldatum select & Alle anzeigen and default to all LV-Teile selected; fix multiselect darkmode theme; align all ? tooltips to be the same component and enhanced that component with delayed sticky animation, then moved that to the core so we can ALL have fancy sticky tooltips; checked over existing phrasen for spelling mistakes; bunch more css adjustments; fixed a possible bug in getAnwForUid where only uid was verified and not person_id; renamed timeline tab and enabled it for students aswell -> configurable; 🤓; 2026-10-07 19:10:43 +02:00
Johann Hoffmann a5db0862d2 Merge branch 'feature-77588/AnwesenheitenSprint252' into demo 2026-10-01 12:23:01 +02:00
Johann Hoffmann b4fc8dfd45 status entry in legende for missing entry aka "-" 2026-10-01 12:03:34 +02:00
Johann Hoffmann 3470d6ee34 Merge branch 'feature-78489/CMS_Admin_Vue' into demo
# Conflicts:
#	system/phrasesupdate.php
2026-09-30 15:24:46 +02:00
Johann Hoffmann 229352e5e4 Contentcomponents.css want to go to git remote aswell 2026-09-30 15:16:39 +02:00
Johann Hoffmann 3dd9be5ea2 Merge branch 'master' into feature-78489/CMS_Admin_Vue
# Conflicts:
#	system/phrasesupdate.php
2026-09-30 15:13:51 +02:00
Johann Hoffmann 614274f3e1 darkmode stylecheck & adjustments across anw extension; ent management UX changes: datepicker labels, counts for filtered/total/offen/akzeptiert/abgelehnt + a button to just load all open excuse note applications no matter the timespan; added more clear visualization for multi stg students inside student uid col, studiengang col, orgform col and edit entschuldigung modal; fixed a bug in edit entschuldigung modal never showing to upload datum oopsie; 2026-09-30 15:07:45 +02:00
Harald Bamberger ce62713790 Merge branch 'bug-82297/ausweis-date-preview-issue' 2026-09-30 12:11:13 +02:00
Harald Bamberger 34761db664 rename phrase 2026-09-30 10:56:30 +02:00
Harald Bamberger c7131ed775 revert changes to old cis since issue does not occur there 2026-09-30 10:53:01 +02:00
Harald Bamberger b57e1984a8 Merge branch 'master' into bug-82297/ausweis-date-preview-issue 2026-09-30 10:20:28 +02:00
Johann Hoffmann c82a6e4c44 phrasens 2026-09-28 13:31:13 +02:00
Johann Hoffmann 791ae73627 Merge branch 'master' into feature-77588/AnwesenheitenSprint252 2026-09-28 12:35:40 +02:00
Johann Hoffmann 283865aa32 Merge remote-tracking branch 'origin/feature-76994/EntEditByStudentErrorFix' into feature-77588/AnwesenheitenSprint252
# Conflicts:
#	system/phrasesupdate.php
2026-09-28 11:26:11 +02:00
Johann Hoffmann 2bb59c9f66 fix a tabulator issue with dynamic columns and presets; legacy le selection now also uses the legacy loading procedure instead of the new one fetching all le of the lva, with the flag on only the assigned le to the looged in lektor; added phrasen; 2026-09-25 13:47:51 +02:00
Johann Hoffmann ba251c0aa7 Merge branch 'feature-78489/CMS_Admin_Vue' into demo
# Conflicts:
#	system/phrasesupdate.php
2026-09-24 10:21:36 +02:00
Johann Hoffmann 98ceeb862d not only log sophisticated upload errors into log table, also tell the user what went wrong with the upload as per codeigniter 3 upload class; 2026-09-23 15:49:03 +02:00
ma0048 95bd12941a Merge branch 'feature-54225/reihungstest_ablauf_fuer_quereinsteiger' into demo
# Conflicts:
#	cis/testtool/menu.php
#	system/dbupdate_3.4.php
2026-09-16 07:28:20 +02:00
Johann Hoffmann d1bd77efb9 enhanced tree filters: by template, date range, child count, content length, editor; hierarchy display mode for tree search results; removed clickstats to configurable tab behind admin berechtigung; added a cis4 preview iframe which builds content the same way cis4 would do, which resemble the cis4 stylesheets and the resolving of contentcomponents; block clickcounting for cis4 preview to not skew statistics; Children Tab is now a Hierarchy Tab which shows parents aswell; added drag&drop sort; Delete Tab shows existing references with db table name; content components got default cis4 styling; added tiefe parameter for recursive lookup of organisationseinheiten or dms groups; DMS query also checks user entitlement since there might be sensitive documents in reach if a high enough dms group would be chosen with a deep enough tiefe; version select has a shorthand mode for contents with large versioncount; 2026-09-08 11:42:39 +02:00
Johann Hoffmann 086d20c1c9 staged missing file 2026-09-02 09:44:28 +02:00
Johann Hoffmann 9f29a7798a introduced dynamic cms content blocks, which can be predefined by adding another vue component to contentcomponents and following the catalog.js/components.js registration convention, which exists to avoid enabling the use of vue components inside content which where never designed for isolated use. contentcomponents could also be reused by traditional vue apps/components if need be; added the most used/requested dynamic content building blocks: OePersonen.js - all persons of a chosen OE; PersonKontakt.js - same but by uid; DmsListe.js - file links of a dms category; DmsDokumente.js - same but by dms_id; scan.js finds the divs inside cms contents tagged with "data-fhc-content" and teleports the actual vue component into the DOM structure of the rendered content. If there are issues/problems with the defined & allowed properties the component is skipped to avoid breaking pages with faulty configs; WIP testing edge cases with such vue content blocks and refining the feature idea in general; 2026-09-01 15:31:55 +02:00
Johann Hoffmann 09ee72bd50 fixed error paths in Auth_Controller for content since it has no terminateWithError; tree reloads only nodes affected instead of complete refetch; simplified url & language options; added fhcAlert toasts to api calls; some bugfixes; still pondering how to properly utilize generic placeholders in contents; 2026-08-28 13:28:15 +02:00
Johann Hoffmann 6af2c7e11a improved content tabs behaviour all around; changed lock mechanism to 24 hour expiry if not manually unlocked; added rank by clicks (sourced from system.tbl_webservicelog) since timestamp filter feature in order to find out which contents are important; general UI improvements; a ton of phrasen; some code cleanup; WIP finetuning & revamping the content features themselves; 2026-08-27 17:57:39 +02:00
Ivymaster 5fff45e302 Added check for date existence 2026-08-27 11:00:43 +02:00
Johann Hoffmann 86a8350bdc fix phrasesupdate.php missing colon 2026-08-27 09:32:59 +02:00
Johann Hoffmann 943999cef9 adapt filter/tablePresets/tablePresetsModule to account for dynamic tables with a new presetColumns property. by default the preset holds all columns, if a scope is given it only holds those columns, their header filters and their sort; on apply the scope is merged into the current table columns so the dynamic ones keep their place; 2026-08-26 16:45:06 +02:00
Johann Hoffmann 6d9ce91302 Merge remote-tracking branch 'origin/master' into feature-77588/AnwesenheitenSprint252
# Conflicts:
#	system/phrasesupdate.php
2026-08-26 16:31:28 +02:00
Johann Hoffmann 1802f9d32c cms admin first draft, needs a lot of polishing still; 2026-08-26 11:18:26 +02:00
Johann Hoffmann 7e01142016 anw widget sql scripts for standalone linklist and once utilizing fhc-calendar list view; also added anw link in lehreinheiten renderer modalContent as new row 2026-07-21 18:30:24 +02:00
Johann Hoffmann 04d90f53d3 adapt stundeplanlib expandObjectInformation when called by getEventsEmployee with an event called extendStundenplanData, which allows extensions like digi anw to plant their own data object inside the stundenplan events in order to conditionally render something like currently in the lehreinheiten calendarEvent.js renderer -> parameterized link to anw extension in order to avoid duplicating functionality across widgets when there is already one doing it very well; 2026-07-20 16:40:57 +02:00
Johann Hoffmann 7fdfa1481e new phrases 2026-07-09 16:00:42 +02:00
Johann Hoffmann 285a2921e4 code cleanup & refactoring lektor frontend/backend; transactions on important concurrent operations like deleteAnwesenheitskontrolle & getNewQRCode; show all flag saved/fetch from local storage; hardcoded strings -> phrasen; 2026-07-03 14:23:14 +02:00
87 changed files with 16051 additions and 255 deletions
+21
View File
@@ -0,0 +1,21 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
// Preferred organisational unit for a new content.
// If the user is entitled for this unit, it wins.
// If not, the first entitled unit wins.
$config['default_oe_kurzbz'] = 'etw';
// Lifetime of an edit lock in hours, counted from tbl_contentlog.start. Must be > 0.
$config['lock_ttl_hours'] = 24;
// Switches the click statistics off completely. With false the admin tab stays hidden and
// the endpoint refuses every request, whatever the rights of the caller are.
$config['clickstats_enabled'] = true;
// Default period of the click statistics in months, when the caller sends none.
$config['clickstats_months'] = 12;
// Length of the ranking in the click statistics tab.
$config['clickstats_limit'] = 100;
+4
View File
@@ -75,6 +75,10 @@ $route['Abgabetool/Student'] = 'Cis/Abgabetool/Student';
$route['Abgabetool/Student/(:any)'] = 'Cis/Abgabetool/Student/$1';
$route['Abgabetool/Deadlines'] = 'Cis/Abgabetool/Deadlines';
// CMS Admin App Routes
$route['cms'] = 'cms/index';
$route['cms/.*'] = 'cms/index';
// Studierendenverwaltung App Routes
$route['studvw'] = 'studentenverwaltung/index';
$route['studvw/.*'] = 'studentenverwaltung/index';
+10 -7
View File
@@ -44,19 +44,22 @@ class Cms extends Auth_Controller
*
* @return void
*/
public function content($content_id, $version = null, $sprache = null, $sichtbar = true)
public function content($content_id = null, $version = null, $sprache = null, $sichtbar = true)
{
// return early if the content_id for the content is missing
if (!isset($content_id))
$this->terminateWithError("content_id is missing");
if (!is_numeric($content_id))
show_404();
$content = $this->ContentModel->load($content_id);
if (isError($content))
$this->terminateWithError(getError($content));
{
show_error('The content could not be loaded');
}
$content = getData($content);
if (NULL === $content)
$this->terminateWithError("Content not found");
if (NULL === $content) {
show_404();
}
$content = current($content);
+23
View File
@@ -0,0 +1,23 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class Cms extends Auth_Controller
{
public function __construct()
{
parent::__construct(['index' => 'basis/cms:r']);
$this->load->library('PermissionLib');
$this->config->load('cms');
$this->loadPhrases(['global', 'ui', 'cms']);
}
public function index()
{
// decide whether the click statistics tab is shown at all.
$this->load->view('Cms.php', [
'clickstats' => $this->config->item('clickstats_enabled')
&& $this->permissionlib->isBerechtigt('admin')
]);
}
}
+340 -6
View File
@@ -25,6 +25,10 @@ if (! defined('BASEPATH')) exit('No direct script access allowed');
*/
class Cms extends FHCAPI_Controller
{
// Upper bound for the tiefe of oe-personen and dms-liste. A hierarchy this deep does
// not occur, and the cap keeps one marker from walking a whole tree.
const MAX_TIEFE = 10;
/**
* Object initialization
@@ -39,6 +43,11 @@ class Cms extends FHCAPI_Controller
'news' => self::PERM_LOGGED,
'getNewsRowCount' => self::PERM_LOGGED,
'getNews' => self::PERM_LOGGED,
'getOePersonen' => self::PERM_LOGGED,
'getDmsKategorie' => self::PERM_LOGGED,
'getContentChilds' => self::PERM_LOGGED,
'getPerson' => self::PERM_LOGGED,
'getDmsDokumente' => self::PERM_LOGGED,
]);
@@ -55,11 +64,7 @@ class Cms extends FHCAPI_Controller
]);
}
//------------------------------------------------------------------------------------------------------------------
// Private methods
//------------------------------------------------------------------------------------------------------------------
// Public methods
@@ -80,7 +85,10 @@ class Cms extends FHCAPI_Controller
$sprache = $this->input->get("sprache",TRUE);
$sichtbar = $this->input->get("sichtbar",TRUE);
$content = $this->cmslib->getContent($content_id, $version, $sprache, $sichtbar);
// cms admin preview renders a content without counting a view
$preview = $this->input->get("preview",TRUE);
$content = $this->cmslib->getContent($content_id, $version, $sprache, $sichtbar, !$preview);
$content = $this->getDataOrTerminateWithError($content);
$this->terminateWithSuccess($content);
@@ -203,5 +211,331 @@ class Cms extends FHCAPI_Controller
}
/**
* Lists the people assigned to an organisation unit.
* Used by the CMS content component "oe-personen".
*/
public function getOePersonen()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('oe_kurzbz', 'Organisationseinheit', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
$this->load->model('organisation/Organisationseinheit_model', 'OrganisationseinheitModel');
$oe_kurzbz = $this->input->get('oe_kurzbz', TRUE);
$mitFoto = $this->input->get('foto', TRUE) ? true : false;
// tiefe 0 asks about the one unit. Deeper takes the sub units with it.
$oeResult = $this->OrganisationseinheitModel->getMitUntereinheiten(
$oe_kurzbz, $this->_tiefe()
);
$oeRows = $this->getDataOrTerminateWithError($oeResult) ?: array();
$uids = array();
$funktionen = array();
foreach ($oeRows as $oe)
{
// 'oezuordnung' is the function that assigns an employee to an organisation
// unit. The model already filters datum_von and datum_bis.
$rows = $this->BenutzerfunktionModel->getBenutzerFunktionenDetailed(
'oezuordnung', $oe->oe_kurzbz
);
$rows = $this->getDataOrTerminateWithError($rows) ?: array();
foreach ($rows as $row)
{
// The model returns the aktiv flag but does not filter on it.
if (!$row->aktiv)
continue;
// One person can hold the assignment in several units of the walk. The
// first one wins, so the list keeps one block per person.
if (isset($funktionen[$row->uid]))
continue;
$uids[] = $row->uid;
// tbl_benutzerfunktion.bezeichnung is the free label of one assignment,
// for example "Bibliothekarin". tbl_funktion.beschreibung is the generic
// name.
$funktionen[$row->uid] = $row->bezeichnung ?: $row->beschreibung;
}
}
$this->terminateWithSuccess($this->_cmsPersonen($uids, $funktionen, $mitFoto));
}
/**
* Contact data of one person.
* Used by the CMS content component "person-block".
*/
public function getPerson()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('uid', 'UID', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$uid = $this->input->get('uid', TRUE);
$mitFoto = $this->input->get('foto', TRUE) ? true : false;
// The function label comes from the marker, not from the database. The component
// passes it through as a property.
$personen = $this->_cmsPersonen(array($uid), array(), $mitFoto);
$this->terminateWithSuccess($personen ? current($personen) : null);
}
/**
* Lists the published documents of one DMS category.
* Used by the CMS content component "dms-liste".
*/
public function getDmsKategorie()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$kategorie_kurzbz = $this->input->get('kategorie_kurzbz', TRUE);
$erlaubt = $this->_dmsKategorieErlaubt($kategorie_kurzbz);
if ($erlaubt === null)
$this->terminateWithError('DMS category not found', self::ERROR_TYPE_404);
// a missing entitlement answers with success and an empty list, not with
// an error. The component sits inside a public content page. An error would raise a
// toast for every reader who is simply not in the group.
$dokumente = array();
if ($erlaubt)
{
// tiefe 0 asks about the one category. Deeper takes the sub categories with it.
$katResult = $this->DmsModel->getMitUnterkategorien(
$kategorie_kurzbz, $this->_tiefe()
);
$kategorien = $this->getDataOrTerminateWithError($katResult) ?: array();
foreach ($kategorien as $kat)
{
// Every category carries its own group rule, so a sub category can be
// closed while the parent is open. Ask for each one instead of letting the
// entitlement of the root stand for the whole walk. The root is already
// answered above.
if ($kat->kategorie_kurzbz !== $kategorie_kurzbz
&& $this->_dmsKategorieErlaubt($kat->kategorie_kurzbz) !== true)
continue;
$rows = $this->DmsModel->getKategorieDokumente($kat->kategorie_kurzbz);
$rows = $this->getDataOrTerminateWithError($rows) ?: array();
foreach ($rows as $row)
$dokumente[] = $row;
}
}
$this->terminateWithSuccess(array(
'zugriff' => $erlaubt,
'dokumente' => $dokumente
));
}
/**
* Lists named documents, in the order the caller asked for them.
* Used by the CMS content component "dms-dokumente".
*/
public function getDmsDokumente()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('dms_ids', 'DMS-IDs', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Dms_model', 'DmsModel');
$ids = array();
foreach (explode(',', $this->input->get('dms_ids', TRUE)) as $teil)
{
$teil = trim($teil);
if (is_numeric($teil))
$ids[] = (int) $teil;
}
// An unbounded list would let one marker fetch the whole DMS.
$ids = array_slice(array_unique($ids), 0, 50);
$dokumente = $this->DmsModel->getDokumenteByIds($ids);
$dokumente = $this->getDataOrTerminateWithError($dokumente) ?: array();
// A hand picked list holds few categories, so deciding per category is cheap and
// reuses the rule of getDmsKategorie().
$erlaubte = array();
$nachId = array();
foreach ($dokumente as $dokument)
{
if (!array_key_exists($dokument->kategorie_kurzbz, $erlaubte))
$erlaubte[$dokument->kategorie_kurzbz] = $this->_dmsKategorieErlaubt($dokument->kategorie_kurzbz);
if ($erlaubte[$dokument->kategorie_kurzbz] === true)
$nachId[$dokument->dms_id] = $dokument;
}
// The editor chose the order. Keep it.
$sortiert = array();
foreach ($ids as $id)
{
if (isset($nachId[$id]))
$sortiert[] = $nachId[$id];
}
$this->terminateWithSuccess($sortiert);
}
/**
* Lists the child pages of a content, as a reader may see them.
* Used by the CMS content component "contentchild-menu".
*/
public function getContentChilds()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural_no_zero');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (!$sprache)
$sprache = getUserLanguage();
$childs = $this->ContentchildModel->getChildsForReader($content_id, $sprache, getAuthUID());
$this->terminateWithSuccess($this->getDataOrTerminateWithError($childs) ?: array());
}
//------------------------------------------------------------------------------------------------------------------
// Private methods
/**
* Builds the shape both person content components render.
*
* The assembly follows Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the
* same in the news extras and on a content page.
*
* @param array $uids in the order the result should keep
* @param array $funktionen uid => function label, may be empty
* @param boolean $mitFoto
* @return array
*/
private function _cmsPersonen($uids, $funktionen, $mitFoto)
{
if (empty($uids))
return array();
$this->load->model('person/Person_model', 'PersonModel');
$kontakt = $this->PersonModel->getCmsKontakt($uids, $mitFoto);
$kontakt = $this->getDataOrTerminateWithError($kontakt) ?: array();
$nachUid = array();
foreach ($kontakt as $row)
$nachUid[$row->uid] = $row;
$personen = array();
// Loops over $uids, not over the result, so the caller keeps its order.
foreach ($uids as $uid)
{
if (!isset($nachUid[$uid]))
continue;
$row = $nachUid[$uid];
$name = trim(implode(' ', array_filter(array(
$row->titelpre, $row->vorname, $row->nachname, $row->titelpost
))));
$telefon = '';
if ($row->telefonklappe)
$telefon = trim(($row->kontakt ?: '') . ' - ' . $row->telefonklappe);
$email = '';
if (defined('DOMAIN'))
$email = ($row->alias ?: $row->uid) . '@' . DOMAIN;
$personen[] = array(
'uid' => $row->uid,
'name' => $name,
'funktion' => isset($funktionen[$uid]) ? $funktionen[$uid] : '',
'telefon' => $telefon,
'email' => $email,
'ort' => $row->planbezeichnung,
'foto' => $row->foto
);
}
return $personen;
}
/**
* Decides whether the current user may see a DMS category.
*
* Same rule as cms/dms.php: an unlocked category is open to everyone. A locked one
* needs a granted group, or the category permission together with basis/dms.
*
* @param string $kategorie_kurzbz
* @return boolean|null null when the category does not exist
*/
/**
* Reads the tiefe parameter of a content component. 0 keeps the component to the unit
* or category it names, which is what a marker without the attribute means.
* Capped, because the depth drives a recursive walk and one query per level found.
*
* @return int
*/
private function _tiefe()
{
$tiefe = (int) $this->input->get('tiefe', TRUE);
if ($tiefe < 0)
return 0;
return min($tiefe, self::MAX_TIEFE);
}
private function _dmsKategorieErlaubt($kategorie_kurzbz)
{
$this->load->library('PermissionLib');
$this->load->model('content/Dms_model', 'DmsModel');
$zugriff = $this->DmsModel->getKategorieZugriff($kategorie_kurzbz, getAuthUID());
$zugriff = $this->getDataOrTerminateWithError($zugriff);
if (empty($zugriff))
return null;
$kategorie = current($zugriff);
if (!$kategorie->gesperrt || $kategorie->in_gruppe)
return true;
$erlaubt = $this->permissionlib->isBerechtigt('basis/dms');
if ($erlaubt && $kategorie->berechtigung_kurzbz != '')
$erlaubt = $this->permissionlib->isBerechtigt($kategorie->berechtigung_kurzbz);
return $erlaubt;
}
}
@@ -0,0 +1,760 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdmin extends FHCAPI_Controller
{
const MAX_TREE_DEPTH = 50;
public function __construct()
{
parent::__construct([
'getTree' => ['basis/cms:r'],
'getSubtree' => ['basis/cms:r'],
'getContent' => ['basis/cms:r'],
'getContentsprache' => ['basis/cms:r'],
'getTemplates' => ['basis/cms:r'],
'getOrganisationseinheiten' => ['basis/cms:r'],
'getSprachen' => ['basis/cms:r'],
'getDmsKategorien' => ['basis/cms:r'],
'getMitarbeiter' => ['basis/cms:r'],
'getDmsKategorieDokumente' => ['basis/cms:r'],
'getUsage' => ['basis/cms:r'],
'getTreeMeta' => ['basis/cms:r'],
'getAncestors' => ['basis/cms:r'],
'getClickCount' => ['basis/cms:r'],
'getClickRanking' => ['basis/cms:r'],
'postContent' => ['basis/cms:rw'],
'postTranslation' => ['basis/cms:rw'],
'postVersion' => ['basis/cms:rw'],
'putProperties' => ['basis/cms:rw'],
'deleteContent' => ['basis/cms:rw'],
'deleteContentsprache' => ['basis/cms:rw']
]);
$this->load->library('CmsAdminLib');
$this->load->library('PermissionLib');
$this->load->model('content/Content_model', 'ContentModel');
$this->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$this->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->load->model('content/Template_model', 'TemplateModel');
$this->loadPhrases(['global', 'cms']);
}
public function getTree()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('menu', 'Menu', 'required|in_list[content,news]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$menu = $this->input->get('menu', TRUE);
$filter = $this->input->get('filter', TRUE);
$entitledResult = $this->cmsadminlib->getEntitledOe();
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? [];
$nodes = [];
if ($menu === 'news')
{
$newsResult = $this->ContentModel->getNewsContent();
if (!isError($newsResult) && getData($newsResult))
{
foreach (getData($newsResult) as $row)
{
$titel = '';
$csResult = $this->ContentspracheModel->getOne(
(int) $row->content_id, DEFAULT_LANGUAGE
);
if (!isError($csResult))
$titel = getData($csResult)->titel;
$node = new stdClass();
$node->content_id = (int) $row->content_id;
$node->titel = mb_substr($titel, 0, 15) . ' '
. date('d.m.Y', strtotime($row->insertamum));
$node->template_kurzbz = $row->template_kurzbz;
$node->oe_kurzbz = $row->oe_kurzbz;
$node->aktiv = ($row->aktiv === 't' || $row->aktiv === true);
$node->entitled = false;
$node->groups = [];
$node->children = [];
$nodes[] = $node;
}
}
}
else
{
if (!empty($filter))
{
$clean = str_replace(['!', '.', '?', ','], '', $filter);
$searchItems = preg_split('/\s+/', $clean, -1, PREG_SPLIT_NO_EMPTY);
$rootsResult = $this->ContentModel->searchCms($searchItems);
}
else
{
$rootsResult = $this->ContentModel->getRootContent();
}
if (!isError($rootsResult) && getData($rootsResult))
{
$visited = [];
foreach (getData($rootsResult) as $row)
{
$node = $this->buildTreeNode(
(int) $row->content_id, 0, $visited
);
if ($node !== null)
$nodes[] = $node;
}
}
}
$allIds = [];
$this->collectContentIds($nodes, $allIds);
$groupMap = [];
if (!empty($allIds))
{
$gResult = $this->ContentgruppeModel->getGruppenForContents($allIds);
if (!isError($gResult))
$groupMap = getData($gResult);
}
$this->applyMeta($nodes, $entitledOe, $groupMap);
$result = [];
foreach ($nodes as $node)
{
$pruned = $this->pruneUnentitled($node);
if ($pruned !== null)
$result[] = $pruned;
}
$this->terminateWithSuccess($result);
}
// One subtree in the shape of a getTree root node. Lets the client replace a branch
// instead of rebuilding the whole tree after a change.
// The visited map starts empty here, so a node with several parents can show up twice
// until the next full reload. Rebuilding the whole tree on every change costs more.
public function getSubtree()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = (int) $this->input->get('content_id', TRUE);
$entitledResult = $this->cmsadminlib->getEntitledOe();
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult);
$visited = [];
$node = $this->buildTreeNode($content_id, 0, $visited);
// Deleted meanwhile. The client drops the branch.
if ($node === null)
$this->terminateWithSuccess(null);
$nodes = [$node];
$allIds = [];
$this->collectContentIds($nodes, $allIds);
$groupMap = [];
if (!empty($allIds))
{
$gResult = $this->ContentgruppeModel->getGruppenForContents($allIds);
if (!isError($gResult))
$groupMap = getData($gResult);
}
$this->applyMeta($nodes, $entitledOe, $groupMap);
$this->terminateWithSuccess($this->pruneUnentitled($node));
}
public function getContent()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$entitledResult = $this->cmsadminlib->isEntitledForContent($content_id);
$entitled = $this->getDataOrTerminateWithError($entitledResult);
if (!$entitled)
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDiesenEintrag'));
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
$contentData = $this->getDataOrTerminateWithError($contentResult);
if (empty($contentData))
$this->terminateWithError($this->p->t('cms', 'contentNichtGefunden'));
$content = $contentData[0];
$langResult = $this->ContentspracheModel->getLanguages($content_id);
$languages = $this->getDataOrTerminateWithError($langResult);
$versions = new stdClass();
foreach ($languages as $sprache)
{
$vResult = $this->ContentspracheModel->getVersions($content_id, $sprache);
if (!isError($vResult) && getData($vResult))
{
$versions->$sprache = array_map(function ($v) {
return (int) $v->version;
}, getData($vResult));
}
}
$result = new stdClass();
$result->content_id = (int) $content->content_id;
$result->template_kurzbz = $content->template_kurzbz;
$result->oe_kurzbz = $content->oe_kurzbz;
$result->aktiv = ($content->aktiv === 't' || $content->aktiv === true);
$result->menu_open = ($content->menu_open === 't' || $content->menu_open === true);
$result->beschreibung = $content->beschreibung ?: '';
$result->entitled = true;
$result->languages = $languages;
$result->versions = $versions;
// Tells the CIS4 preview whether this template renders there at all.
$cis4Result = $this->TemplateModel->hasCis4Stylesheet($content->template_kurzbz);
$result->has_cis4_stylesheet = !isError($cis4Result) && getData($cis4Result);
$this->terminateWithSuccess($result);
}
public function getContentsprache()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$result = $this->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($result))
$this->terminateWithError($this->p->t('cms', 'versionNichtGefunden'));
$data = getData($result);
$data->sichtbar = ($data->sichtbar === 't' || $data->sichtbar === true);
$this->terminateWithSuccess($data);
}
// The tree lists a search hit out of its place, so a generic title carries no context.
// This answers where each one sits. One walk for the whole list, not one per node.
const MAX_ANCESTOR_TIEFE = 12;
/**
* Ancestors of the given contents, keyed by content_id and ordered from the top down.
*/
public function getAncestors()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_ids', 'Content IDs', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$ids = [];
foreach (explode(',', $this->input->get('content_ids', TRUE)) as $teil)
{
$teil = trim($teil);
if (is_numeric($teil))
$ids[] = (int) $teil;
}
$ids = array_values(array_unique($ids));
if (empty($ids))
$this->terminateWithSuccess(new stdClass());
$result = $this->ContentchildModel->getAncestors(
$ids, self::MAX_ANCESTOR_TIEFE, DEFAULT_LANGUAGE
);
$rows = $this->getDataOrTerminateWithError($result);
// Same rule as the tree: an entry of an organisational unit the editor is not
// entitled for stays readable but cannot be opened.
$entitledResult = $this->cmsadminlib->getEntitledOe();
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? [];
// The query orders by the distance, farthest first, so appending keeps the path
// reading from the top down.
$pfade = [];
foreach ((array) $rows as $row)
{
$start = (int) $row->start_id;
if (!isset($pfade[$start]))
$pfade[$start] = [];
$pfade[$start][] = [
'content_id' => (int) $row->content_id,
'titel' => $row->titel,
'tiefe' => (int) $row->tiefe,
'entitled' => in_array($row->oe_kurzbz, $entitledOe)
];
}
$this->terminateWithSuccess($pfade ?: new stdClass());
}
/**
* Per content metadata for the tree filter, keyed by content_id.
* One query for the whole tree, so the client filters and sorts without a round trip.
*/
public function getTreeMeta()
{
$result = $this->ContentModel->getTreeMeta(getAuthUID(), DEFAULT_LANGUAGE);
$rows = $this->getDataOrTerminateWithError($result);
$meta = [];
foreach ((array) $rows as $row)
{
$meta[(int) $row->content_id] = [
'childcount' => (int) $row->childcount,
'insertamum' => $row->insertamum,
'updateamum' => $row->updateamum,
'mine' => ($row->mine === 't' || $row->mine === true),
'contentlength' => (int) $row->contentlength
];
}
$this->terminateWithSuccess($meta);
}
/**
* Two gates guard every click statistics request: the config flag switches the feature
* off for everybody, the admin right decides who may read the numbers.
*/
private function checkClickstatsAccess()
{
$this->config->load('cms');
if (!$this->config->item('clickstats_enabled'))
$this->terminateWithError($this->p->t('cms', 'klickstatistikDeaktiviert'));
if (!$this->permissionlib->isBerechtigt('admin'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
}
/**
* Period of a click statistics request. 0 counts the whole log.
* Call it after checkClickstatsAccess, which loads the config.
* @return array months and the start date, null for the whole log
*/
private function clickstatsPeriod()
{
$months = $this->input->get('months');
if ($months === null || $months === '')
$months = $this->config->item('clickstats_months');
$months = (int) $months;
$since = ($months > 0)
? date('Y-m-d H:i:s', strtotime('-' . $months . ' months'))
: null;
return [$months, $since];
}
/**
* Views of one content. One counting query, so the tab loads it right away.
* Empty if LOG_CONTENT is off.
*/
public function getClickCount()
{
$this->checkClickstatsAccess();
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('months', 'Monate', 'is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('system/Webservicelog_model', 'WebservicelogModel');
list($months, $since) = $this->clickstatsPeriod();
$result = $this->WebservicelogModel->getContentClickCount(
(int) $this->input->get('content_id', TRUE), $since
);
$this->terminateWithSuccess([
'since' => $since,
'months' => $months,
'own' => $this->getDataOrTerminateWithError($result)
]);
}
/**
* The ranking of the most viewed contents. It groups the whole log and resolves a
* title per row, which takes seconds, so the tab fetches it only on request.
* Empty if LOG_CONTENT is off.
*/
public function getClickRanking()
{
$this->checkClickstatsAccess();
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('months', 'Monate', 'is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('system/Webservicelog_model', 'WebservicelogModel');
list($months, $since) = $this->clickstatsPeriod();
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->WebservicelogModel->getContentClickCounts(
$since, $sprache, (int) $this->config->item('clickstats_limit')
);
$rows = $this->getDataOrTerminateWithError($result);
$ranked = [];
foreach ((array) $rows as $row)
{
$ranked[] = [
'content_id' => (int) $row->content_id,
'titel' => $row->titel,
'hits' => (int) $row->hits
];
}
$this->terminateWithSuccess([
'since' => $since,
'months' => $months,
'ranked' => $ranked
]);
}
public function getTemplates()
{
$this->TemplateModel->addSelect('template_kurzbz, bezeichnung');
$this->TemplateModel->addOrder('bezeichnung');
$result = $this->TemplateModel->loadWhere([]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getOrganisationseinheiten()
{
$this->load->model(
'organisation/Organisationseinheit_model', 'OrganisationseinheitModel'
);
$this->OrganisationseinheitModel->addSelect(
'oe_kurzbz, bezeichnung, organisationseinheittyp_kurzbz, aktiv'
);
$this->OrganisationseinheitModel->addOrder('bezeichnung');
$result = $this->OrganisationseinheitModel->loadWhere([]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getSprachen()
{
$this->load->model('system/Sprache_model', 'SpracheModel');
$this->SpracheModel->addSelect('sprache, bezeichnung');
$result = $this->SpracheModel->loadWhere(['content' => true]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getUsage()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$result = $this->ContentModel->getUsage($content_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postContent()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('parent_content_id', 'Parent', 'is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$parent = $this->input->post('parent_content_id');
if (empty($parent))
$parent = null;
$result = $this->cmsadminlib->createContent($parent);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postTranslation()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
$this->form_validation->set_rules('target_sprache', 'Target Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->createTranslation(
$this->input->post('content_id'),
$this->input->post('sprache'),
$this->input->post('version'),
$this->input->post('target_sprache')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postVersion()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->createVersion(
$this->input->post('content_id'),
$this->input->post('sprache')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function putProperties()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
$this->form_validation->set_rules('template_kurzbz', 'Template', 'required');
$this->form_validation->set_rules('oe_kurzbz', 'OE', 'required');
$this->form_validation->set_rules('titel', 'Titel', 'required|max_length[256]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$daten = [
'content_id' => $this->input->post('content_id'),
'sprache' => $this->input->post('sprache'),
'version' => $this->input->post('version'),
'template_kurzbz' => $this->input->post('template_kurzbz'),
'oe_kurzbz' => $this->input->post('oe_kurzbz'),
'titel' => $this->input->post('titel'),
'beschreibung' => $this->input->post('beschreibung') ?: '',
'aktiv' => filter_var($this->input->post('aktiv'), FILTER_VALIDATE_BOOLEAN),
'menu_open' => filter_var($this->input->post('menu_open'), FILTER_VALIDATE_BOOLEAN),
'sichtbar' => filter_var($this->input->post('sichtbar'), FILTER_VALIDATE_BOOLEAN)
];
$result = $this->cmsadminlib->saveProperties($daten);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteContent()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'd'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->deleteContent($this->input->post('content_id'));
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteContentsprache()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'd'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->validateContentSpracheVersion();
$result = $this->cmsadminlib->deleteVersion(
$this->input->post('content_id'),
$this->input->post('sprache'),
$this->input->post('version')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
// --- Private helpers ---
private function validateContentSpracheVersion()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
}
private function buildTreeNode($content_id, $depth, &$visited)
{
if ($depth > self::MAX_TREE_DEPTH || isset($visited[$content_id]))
return null;
$visited[$content_id] = true;
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
if (isError($contentResult) || empty(getData($contentResult)))
return null;
$content = getData($contentResult)[0];
$titel = '';
$csResult = $this->ContentspracheModel->getOne($content_id, DEFAULT_LANGUAGE);
if (!isError($csResult))
$titel = getData($csResult)->titel;
$childrenResult = $this->ContentchildModel->getChilds($content_id, DEFAULT_LANGUAGE);
$children = [];
if (!isError($childrenResult) && getData($childrenResult))
{
foreach (getData($childrenResult) as $child)
{
$childNode = $this->buildTreeNode(
(int) $child->child_content_id, $depth + 1, $visited
);
if ($childNode !== null)
$children[] = $childNode;
}
}
$node = new stdClass();
$node->content_id = (int) $content_id;
$node->titel = $titel;
$node->template_kurzbz = $content->template_kurzbz;
$node->oe_kurzbz = $content->oe_kurzbz;
$node->aktiv = ($content->aktiv === 't' || $content->aktiv === true);
$node->entitled = false;
$node->groups = [];
$node->children = $children;
return $node;
}
private function collectContentIds($nodes, &$ids)
{
foreach ($nodes as $node)
{
$ids[] = $node->content_id;
if (!empty($node->children))
$this->collectContentIds($node->children, $ids);
}
}
private function applyMeta(&$nodes, $entitledOe, $groupMap)
{
foreach ($nodes as $node)
{
$node->entitled = in_array($node->oe_kurzbz, $entitledOe);
$node->groups = isset($groupMap[$node->content_id])
? $groupMap[$node->content_id]
: [];
if (!empty($node->children))
$this->applyMeta($node->children, $entitledOe, $groupMap);
}
}
private function pruneUnentitled($node)
{
$kept = [];
foreach ($node->children as $child)
{
$pruned = $this->pruneUnentitled($child);
if ($pruned !== null)
$kept[] = $pruned;
}
$node->children = $kept;
if ($node->entitled || !empty($node->children))
return $node;
return null;
}
/**
* Lists the DMS categories for the contentcomponent insert dialog.
*/
public function getDmsKategorien()
{
$this->load->model('content/Dms_model', 'DmsModel');
$result = $this->DmsModel->getKategorien();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
/**
* Lists active staff for the contentcomponent insert dialog.
* Names only. See Mitarbeiter_model::getAktivesPersonalNamen().
*/
public function getMitarbeiter()
{
$this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
$result = $this->MitarbeiterModel->getAktivesPersonalNamen();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
/**
* Lists the documents of one DMS category for the contentcomponent insert dialog.
*/
public function getDmsKategorieDokumente()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Dms_model', 'DmsModel');
$result = $this->DmsModel->getKategorieDokumenteAlle($this->input->get('kategorie_kurzbz', TRUE));
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result) ?: array());
}
}
@@ -0,0 +1,195 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdminInhalt extends FHCAPI_Controller
{
public function __construct()
{
parent::__construct([
'getFormSchema' => ['basis/cms:r'],
'getFormData' => ['basis/cms:r'],
'getLock' => ['basis/cms:r'],
'getVersions' => ['basis/cms:r'],
'putFormData' => ['basis/cms:rw'],
'postLock' => ['basis/cms:rw'],
'deleteLock' => ['basis/cms:rw'],
'deleteLockForced' => ['basis/cms:rw']
]);
$this->load->library('CmsAdminLib');
$this->load->library('XsdSchemaLib');
$this->load->library('PermissionLib');
$this->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->load->model('content/Template_model', 'TemplateModel');
$this->loadPhrases(['global', 'cms']);
}
public function getFormSchema()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('template_kurzbz', 'Template', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$template_kurzbz = $this->input->get('template_kurzbz', TRUE);
$this->TemplateModel->resetQuery();
$templateResult = $this->TemplateModel->load($template_kurzbz);
$templateData = $this->getDataOrTerminateWithError($templateResult);
if (empty($templateData))
$this->terminateWithError($this->p->t('cms', 'vorlageNichtGefunden'));
$schemaResult = $this->xsdschemalib->parseSchema(
$templateData[0]->xsd, $template_kurzbz
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($schemaResult));
}
public function getFormData()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$versionResult = $this->ContentspracheModel->getOne($content_id, $sprache, $version);
$row = $this->getDataOrTerminateWithError($versionResult);
$this->load->model('content/Content_model', 'ContentModel');
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
$contentData = $this->getDataOrTerminateWithError($contentResult);
if (empty($contentData))
$this->terminateWithError($this->p->t('cms', 'contentNichtGefunden'));
$this->TemplateModel->resetQuery();
$templateResult = $this->TemplateModel->load($contentData[0]->template_kurzbz);
$templateData = $this->getDataOrTerminateWithError($templateResult);
if (empty($templateData))
$this->terminateWithError($this->p->t('cms', 'vorlageNichtGefunden'));
$schemaResult = $this->xsdschemalib->parseSchema(
$templateData[0]->xsd, $contentData[0]->template_kurzbz
);
$schema = $this->getDataOrTerminateWithError($schemaResult);
$valuesResult = $this->xsdschemalib->extractValues($row->content, $schema);
$values = $this->getDataOrTerminateWithError($valuesResult);
$lockResult = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
$lock = $this->getDataOrTerminateWithError($lockResult);
$this->terminateWithSuccess([
'schema' => $schema,
'values' => $values,
'sperre' => $lock
]);
}
public function getLock()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$result = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getVersions()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$result = $this->ContentspracheModel->getVersions($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function putFormData()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->post('content_id');
$sprache = $this->input->post('sprache');
$version = $this->input->post('version');
$values = $this->input->post('values');
if (is_string($values))
$values = json_decode($values, true);
if (!is_array($values))
$values = [];
$lockResult = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
$lock = $this->getDataOrTerminateWithError($lockResult);
if (!$lock['own'])
$this->terminateWithError($this->p->t('cms', 'speichernNurMitSperre'));
$result = $this->cmsadminlib->saveContentXml($content_id, $sprache, $version, $values);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postLock()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
$contentsprache_id = $this->input->post('contentsprache_id');
$result = $this->cmsadminlib->lock($contentsprache_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteLock()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
$contentsprache_id = $this->input->post('contentsprache_id');
$result = $this->cmsadminlib->releaseOwnLock($contentsprache_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteLockForced()
{
if (!$this->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
$contentsprache_id = $this->input->post('contentsprache_id');
$result = $this->cmsadminlib->forceRelease($contentsprache_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
}
@@ -0,0 +1,265 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdminStruktur extends FHCAPI_Controller
{
public function __construct()
{
parent::__construct([
'getGruppen' => ['basis/cms:r'],
'getAllGruppen' => ['basis/cms:r'],
'getChilds' => ['basis/cms:r'],
'getParents' => ['basis/cms:r'],
'getPossibleChilds' => ['basis/cms:r'],
'postGruppe' => ['basis/cms:rw'],
'deleteGruppe' => ['basis/cms:rw'],
'postChild' => ['basis/cms:rw'],
'deleteChild' => ['basis/cms:rw'],
'putChildSort' => ['basis/cms:rw'],
'putChildOrder' => ['basis/cms:rw']
]);
$this->load->library('PermissionLib');
$this->load->model('content/Content_model', 'ContentModel');
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$this->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->loadPhrases(['global', 'cms']);
}
public function getGruppen()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$result = $this->ContentgruppeModel->getGruppen($content_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getAllGruppen()
{
$result = $this->ContentgruppeModel->getAllGruppen();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getChilds()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->ContentchildModel->getChilds($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getParents()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->ContentchildModel->getParents($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getPossibleChilds()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->ContentModel->getPossibleChilds($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postGruppe()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('gruppe_kurzbz', 'Gruppe', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentgruppeModel->insert([
'content_id' => $this->input->post('content_id'),
'gruppe_kurzbz' => $this->input->post('gruppe_kurzbz'),
'insertamum' => date('Y-m-d H:i:s'),
'insertvon' => getAuthUID()
]);
if (isError($result))
{
$this->terminateWithError($this->p->t('cms', 'gruppeBereitsZugeordnet'));
}
$this->terminateWithSuccess(true);
}
public function deleteGruppe()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('gruppe_kurzbz', 'Gruppe', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentgruppeModel->delete([
$this->input->post('gruppe_kurzbz'),
$this->input->post('content_id')
]);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function postChild()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('child_content_id', 'Child Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->post('content_id');
$sortResult = $this->ContentchildModel->getMaxSort($content_id);
$sort = $this->getDataOrTerminateWithError($sortResult) + 1;
$result = $this->ContentchildModel->insert([
'content_id' => $content_id,
'child_content_id' => $this->input->post('child_content_id'),
'sort' => $sort,
'insertamum' => date('Y-m-d H:i:s'),
'insertvon' => getAuthUID()
]);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function deleteChild()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('contentchild_id', 'Contentchild ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentchildModel->delete($this->input->post('contentchild_id'));
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function putChildSort()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('contentchild_id', 'Contentchild ID', 'required|is_natural');
$this->form_validation->set_rules('direction', 'Direction', 'required|in_list[up,down]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$direction = $this->input->post('direction');
$result = $this->ContentchildModel->swapSort(
$this->input->post('contentchild_id'),
$direction
);
if (isError($result))
{
// The child already sits at the end it was moved towards. That is a normal
// answer, not a failure, and the direction decides the wording.
if (getError($result) === Contentchild_model::NO_NEIGHBOUR)
{
$this->terminateWithError($this->p->t('cms',
$direction === 'up' ? 'bereitsGanzOben' : 'bereitsGanzUnten'));
}
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
}
$this->terminateWithSuccess(true);
}
// Writes the whole order at once. The drag and drop moves a child over any distance,
// and putChildSort would need one request per position.
public function putChildOrder()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$ids = $this->input->post('contentchild_ids');
if (is_string($ids))
$ids = json_decode($ids, true);
if (!is_array($ids) || empty($ids))
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
$result = $this->ContentchildModel->setSortOrder(
$this->input->post('content_id'),
$ids
);
if (isError($result))
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
$this->terminateWithSuccess(true);
}
}
+563
View File
@@ -0,0 +1,563 @@
<?php
if (!defined('BASEPATH'))
exit('No direct script access allowed');
class CmsAdminLib
{
private $ci;
public function __construct()
{
$this->ci =& get_instance();
$this->ci->load->model('content/Content_model', 'ContentModel');
$this->ci->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->ci->load->model('content/Contentchild_model', 'ContentchildModel');
$this->ci->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->ci->load->model('content/Contentlog_model', 'ContentlogModel');
$this->ci->load->model('content/Template_model', 'TemplateModel');
$this->ci->load->library('XsdSchemaLib');
$this->ci->load->library('PermissionLib');
$this->ci->config->load('cms');
}
/**
* @return object success(array) entitled oe_kurzbz values
*/
public function getEntitledOe()
{
$oe = $this->ci->permissionlib->getOE_isEntitledFor('basis/cms');
if ($oe === false || !is_array($oe))
return success([]);
return success($oe);
}
/**
* @param int $content_id
* @return object success(bool)
*/
public function isEntitledForContent($content_id)
{
$oeResult = $this->ci->ContentModel->getOeKurzbz($content_id);
if (isError($oeResult))
return $oeResult;
$entitledResult = $this->getEntitledOe();
if (isError($entitledResult))
return $entitledResult;
return success(in_array(getData($oeResult), getData($entitledResult)));
}
/**
* @return object success(string) or error
*/
public function getDefaultOe()
{
$default = $this->ci->config->item('default_oe_kurzbz');
$entitledResult = $this->getEntitledOe();
if (isError($entitledResult))
return $entitledResult;
$entitled = getData($entitledResult);
if (empty($entitled))
return error($this->ci->p->t('cms', 'keineBerechtigteOe'));
if (in_array($default, $entitled))
return success($default);
return success($entitled[0]);
}
/**
* @param int|null $parent_content_id
* @return object success(array) with content_id, sprache, version
*/
public function createContent($parent_content_id = null)
{
$templatesResult = $this->ci->TemplateModel->loadWhere([]);
if (isError($templatesResult))
return $templatesResult;
$templates = getData($templatesResult);
if (empty($templates))
return error($this->ci->p->t('cms', 'keineVorlageVorhanden'));
$template = $templates[0];
$oeResult = $this->getDefaultOe();
if (isError($oeResult))
return $oeResult;
$oe = getData($oeResult);
$titel = $this->ci->p->t('cms', 'neuerEintrag');
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$this->ci->db->trans_start();
$contentResult = $this->ci->ContentModel->insert([
'template_kurzbz' => $template->template_kurzbz,
'oe_kurzbz' => $oe,
'aktiv' => true,
'menu_open' => false,
'beschreibung' => '',
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($contentResult))
return $contentResult;
$content_id = getData($contentResult);
$spracheResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => DEFAULT_LANGUAGE,
'version' => 1,
'sichtbar' => true,
'titel' => $titel,
'content' => '<?xml version="1.0" encoding="UTF-8" ?><content></content>',
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($spracheResult))
return $spracheResult;
if ($parent_content_id !== null)
{
$maxSortResult = $this->ci->ContentchildModel->getMaxSort($parent_content_id);
if (isError($maxSortResult))
return $maxSortResult;
$childResult = $this->ci->ContentchildModel->insert([
'content_id' => $parent_content_id,
'child_content_id' => $content_id,
'sort' => getData($maxSortResult) + 1,
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($childResult))
return $childResult;
}
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success([
'content_id' => $content_id,
'sprache' => DEFAULT_LANGUAGE,
'version' => 1
]);
}
/**
* @param int $content_id
* @param string $sprache source language
* @param int $version source version
* @param string $neueSprache target language
* @return object success(array) with contentsprache_id
*/
public function createTranslation($content_id, $sprache, $version, $neueSprache)
{
$existsResult = $this->ci->ContentspracheModel->exists($content_id, $neueSprache);
if (isError($existsResult))
return $existsResult;
if (getData($existsResult))
return error($this->ci->p->t('cms', 'uebersetzungExistiert'));
$sourceResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($sourceResult))
return $sourceResult;
$source = getData($sourceResult);
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$insertResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => $neueSprache,
'version' => $source->version,
'sichtbar' => true,
'titel' => $source->titel,
'content' => $source->content,
// The review feature is gone from this admin, but the columns stay
'reviewvon' => null,
'reviewamum' => null,
'gesperrt_uid' => null,
'insertamum' => $now,
'insertvon' => $uid,
'updateamum' => $now,
'updatevon' => $uid
]);
if (isError($insertResult))
return $insertResult;
return success(['contentsprache_id' => getData($insertResult)]);
}
/**
* @param int $content_id
* @param string $sprache
* @return object success(array) with version
*/
public function createVersion($content_id, $sprache)
{
$sourceResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, null);
if (isError($sourceResult))
return $sourceResult;
$source = getData($sourceResult);
$maxResult = $this->ci->ContentspracheModel->getMaxVersion($content_id, $sprache);
if (isError($maxResult))
return $maxResult;
$newVersion = getData($maxResult) + 1;
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$insertResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => $sprache,
'version' => $newVersion,
'sichtbar' => false,
'titel' => $source->titel,
'content' => $source->content,
// The review feature is gone from this admin, but the columns stay
'reviewvon' => null,
'reviewamum' => null,
'gesperrt_uid' => null,
'insertamum' => $now,
'insertvon' => $uid,
'updateamum' => $now,
'updatevon' => $uid
]);
if (isError($insertResult))
return $insertResult;
return success(['version' => $newVersion]);
}
/**
* Writes the properties of one content version.
* @param array $daten content_id, sprache, version, template_kurzbz, oe_kurzbz,
* aktiv, menu_open, beschreibung, titel, sichtbar
* @return object success(true) or error
*/
public function saveProperties($daten)
{
$versionResult = $this->ci->ContentspracheModel->getOne(
$daten['content_id'], $daten['sprache'], $daten['version']
);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$this->ci->db->trans_start();
$this->ci->ContentModel->update($daten['content_id'], [
'template_kurzbz' => $daten['template_kurzbz'],
'oe_kurzbz' => $daten['oe_kurzbz'],
'aktiv' => $daten['aktiv'],
'menu_open' => $daten['menu_open'],
'beschreibung' => $daten['beschreibung'],
'updateamum' => $now,
'updatevon' => $uid
]);
$this->ci->ContentspracheModel->update($row->contentsprache_id, [
'titel' => $daten['titel'],
'sichtbar' => $daten['sichtbar'],
'updateamum' => $now,
'updatevon' => $uid
]);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success(true);
}
// legacy cms checks neither the permission
// type nor the lock. The form appears only when the user holds the lock, but the POST
// itself is unprotected.
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @param array $values field name => value
* @return object success(true) or error
*/
public function saveContentXml($content_id, $sprache, $version, $values)
{
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$contentResult = $this->ci->ContentModel->load($content_id);
if (isError($contentResult))
return $contentResult;
$content = getData($contentResult);
if (empty($content))
return error($this->ci->p->t('cms', 'contentNichtGefunden'));
$templateResult = $this->ci->TemplateModel->load($content[0]->template_kurzbz);
if (isError($templateResult))
return $templateResult;
$template = getData($templateResult);
if (empty($template))
return error($this->ci->p->t('cms', 'keineVorlageVorhanden'));
$schemaResult = $this->ci->xsdschemalib->parseSchema(
$template[0]->xsd, $content[0]->template_kurzbz
);
if (isError($schemaResult))
return $schemaResult;
$xmlResult = $this->ci->xsdschemalib->buildXml(getData($schemaResult), $values);
if (isError($xmlResult))
return $xmlResult;
$updateResult = $this->ci->ContentspracheModel->update(
$row->contentsprache_id,
['content' => getData($xmlResult)]
);
if (isError($updateResult))
return $updateResult;
return success(true);
}
/**
* Locks a version for the current user, or takes over an expired lock.
* @param int $contentsprache_id
* @return object success(true) or error
*/
public function lock($contentsprache_id)
{
$uid = getAuthUID();
$rowResult = $this->ci->ContentspracheModel->load($contentsprache_id);
if (isError($rowResult))
return $rowResult;
$rowData = getData($rowResult);
if (empty($rowData))
return error($this->ci->p->t('cms', 'versionNichtGefunden'));
$holder = ($rowData[0]->gesperrt_uid === '') ? null : $rowData[0]->gesperrt_uid;
if ($holder !== null && $holder !== $uid)
{
$openResult = $this->ci->ContentlogModel->getOpenLock($contentsprache_id, $holder);
$openEntry = (!isError($openResult)) ? getData($openResult) : null;
if (!$this->isLockExpired($holder, $openEntry))
return error($this->ci->p->t('cms', 'bereitsGesperrt'));
}
$this->ci->db->trans_start();
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id);
$logResult = $this->ci->ContentlogModel->insert([
'uid' => $uid,
'contentsprache_id' => $contentsprache_id,
'start' => date('Y-m-d H:i:s')
]);
if (isError($logResult))
return $logResult;
$updateResult = $this->ci->ContentspracheModel->update($contentsprache_id, [
'gesperrt_uid' => $uid
]);
if (isError($updateResult))
return $updateResult;
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success(true);
}
// legacy released ALL locks of the user. Releasing now ends one lock.
// The uid predicate stops a user releasing a lock of somebody else.
/**
* Releases the lock of the current user on one version.
* @param int $contentsprache_id
* @return object success(true)
*/
public function releaseOwnLock($contentsprache_id)
{
$uid = getAuthUID();
$this->ci->db->trans_start();
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id, $uid);
$this->ci->ContentspracheModel->releaseLock($contentsprache_id, $uid);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success(true);
}
/**
* @param int $contentsprache_id
* @return object success(true)
*/
public function forceRelease($contentsprache_id)
{
$this->ci->db->trans_start();
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id);
$this->ci->ContentspracheModel->releaseLock($contentsprache_id);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success(true);
}
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @return object success(array) gesperrt_uid, start, expires, own, expired, may_force
*/
public function getLockState($content_id, $sprache, $version)
{
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
// legacy admin.php inserts an empty string, releasing writes NULL.
// this code reports NULL.
$gesperrt_uid = ($row->gesperrt_uid === '') ? null : $row->gesperrt_uid;
$logEntry = null;
if ($gesperrt_uid !== null)
{
$lockResult = $this->ci->ContentlogModel->getOpenLock(
$row->contentsprache_id, $gesperrt_uid
);
$logEntry = (!isError($lockResult)) ? getData($lockResult) : null;
}
return success([
'contentsprache_id' => (int) $row->contentsprache_id,
'gesperrt_uid' => $gesperrt_uid,
'start' => $logEntry ? $logEntry->start : null,
'own' => ($gesperrt_uid !== null && $gesperrt_uid === getAuthUID()),
'expired' => $this->isLockExpired($gesperrt_uid, $logEntry),
'expires' => $this->lockExpiryDate($logEntry),
'may_force' => $this->ci->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su')
]);
}
/**
* End of the lock window, null if the entry carries no start.
* @param stdClass|null $logEntry
* @return string|null
*/
private function lockExpiryDate($logEntry)
{
if ($logEntry === null || empty($logEntry->start))
return null;
$ttlHours = (int) $this->ci->config->item('lock_ttl_hours');
// lock() writes start with PHP time, so stay on PHP time.
return date('Y-m-d H:i:s', strtotime($logEntry->start) + $ttlHours * 3600);
}
// Locks now age out after lock_ttl_hours.
/**
* A lock without a log entry has no age and counts as expired.
* @param string|null $gesperrt_uid
* @param stdClass|null $logEntry
* @return bool
*/
private function isLockExpired($gesperrt_uid, $logEntry)
{
if ($gesperrt_uid === null)
return false;
$expires = $this->lockExpiryDate($logEntry);
return $expires === null || strtotime($expires) < time();
}
/**
* @param int $content_id
* @return object success(true) or error
*/
public function deleteContent($content_id)
{
$this->ci->db->trans_start();
$this->ci->ContentchildModel->deleteByContent($content_id);
$this->ci->ContentlogModel->deleteByContent($content_id);
$this->ci->ContentspracheModel->deleteByContent($content_id);
$this->ci->ContentgruppeModel->deleteByContent($content_id);
$this->ci->ContentModel->delete($content_id);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'loeschenNichtMoeglichReferenzen'));
return success(true);
}
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @return object success(true) or error
*/
public function deleteVersion($content_id, $sprache, $version)
{
if ($sprache === DEFAULT_LANGUAGE)
{
$countResult = $this->ci->ContentspracheModel->getNumberOfVersions($content_id, $sprache);
if (isError($countResult))
return $countResult;
if (getData($countResult) === 1)
return error($this->ci->p->t('cms', 'letzteVersionNichtLoeschbar'));
}
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$this->ci->db->trans_start();
$this->ci->ContentlogModel->deleteByContentsprache($row->contentsprache_id);
$this->ci->ContentspracheModel->deleteVersion($content_id, $sprache, $version);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'loeschenFehlgeschlagen'));
return success(true);
}
}
+4 -2
View File
@@ -42,10 +42,12 @@ class CmsLib
* @param int $version
* @param string $sprache
* @param boolean $sichtbar
* @param boolean $log false suppresses the click log entry. The CMS admin
* preview renders a content without counting a view.
*
* @return stdClass
*/
public function getContent($content_id, $version = null, $sprache = null, $sichtbar = true)
public function getContent($content_id, $version = null, $sprache = null, $sichtbar = true, $log = true)
{
if (!is_numeric($content_id))
return error('ContentID ist ungueltig');
@@ -72,7 +74,7 @@ class CmsLib
return $content;
// Legt einen Logeintrag für die Klickstatistik an
if (defined('LOG_CONTENT') && LOG_CONTENT) {
if ($log && defined('LOG_CONTENT') && LOG_CONTENT) {
// Nur eingeloggte User werden geloggt, das sonst auch alle Infoscreenaufrufe und dgl. mitgeloggt werden
if (isLogged()) {
$request_data = 'content_id=' . $content_id;
+17 -2
View File
@@ -7,6 +7,7 @@ use \DateTime as DateTime;
use \DateTimeZone as DateTimeZone;
use \DateInterval as DateInterval;
use \DatePeriod as DatePeriod;
use CI3_Events as Events;
class StundenplanLib
{
@@ -143,7 +144,7 @@ class StundenplanLib
return $stundenplan_data;
$stundenplan_data = getData($stundenplan_data) ?? [];
$function_error = $this->expandObjectInformation($stundenplan_data);
$function_error = $this->expandObjectInformation($stundenplan_data, true);
if ($function_error)
return $function_error;
@@ -303,7 +304,7 @@ class StundenplanLib
return success($lektoren);
}
public function expandObjectInformation($data)
public function expandObjectInformation($data, $callExtensionsEvent = false)
{
$this->_ci =& get_instance();
@@ -392,6 +393,20 @@ class StundenplanLib
$item->lektor = $lektor_obj_array;
}
// info retrieved from extensions might not be necessary for some stundenplan use cases so the event
// will be gated behind this flag
if($callExtensionsEvent) {
Events::trigger(
'extendStundenplanData',
function & () use (&$data)
{
return $data;
}
);
}
}
public function fetchFerienTageEvents($start_date, $end_date, $studiengang_kz)
+195
View File
@@ -0,0 +1,195 @@
<?php
if (!defined('BASEPATH'))
exit('No direct script access allowed');
use \DOMDocument as DOMDocument;
class XsdSchemaLib
{
private $ci;
public function __construct()
{
$this->ci =& get_instance();
}
/**
* Converts an XSD into a field schema.
* @param string $xsd the XSD as a string
* @param string $templateKurzbz short name of the template
* @return object success(array) with template_kurzbz, root, fields
*/
public function parseSchema($xsd, $templateKurzbz)
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
if (!$dom->loadXML($xsd))
{
libxml_clear_errors();
return error($this->ci->p->t('cms', 'xsdUngueltig'));
}
libxml_clear_errors();
$root = null;
$fields = array();
$this->_walkElements($dom->documentElement, $root, $fields);
return success(array(
'template_kurzbz' => $templateKurzbz,
'root' => $root,
'fields' => $fields
));
}
/**
* Reads the values of a content from the XML.
* @param string $xml the value of tbl_contentsprache.content
* @param array $schema the result of parseSchema
* @return object success(array) field name => value
*/
public function extractValues($xml, $schema)
{
$values = array();
foreach ($schema['fields'] as $field)
{
$values[$field['name']] = '';
}
if ($xml === null || trim($xml) === '')
{
return success($values);
}
libxml_use_internal_errors(true);
$dom = new DOMDocument();
if (!$dom->loadXML($xml))
{
libxml_clear_errors();
return success($values);
}
libxml_clear_errors();
foreach ($schema['fields'] as $field)
{
$nodes = $dom->getElementsByTagName($field['name']);
if ($nodes->length > 0)
{
$values[$field['name']] = $nodes->item(0)->nodeValue;
}
}
return success($values);
}
/**
* Builds the XML from the values.
* @param array $schema the result of parseSchema
* @param array $values field name => value
* @return object success(string) the XML
*/
public function buildXml($schema, $values)
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
$xml .= "\n<" . $schema['root'] . '>';
foreach ($schema['fields'] as $field)
{
$value = isset($values[$field['name']]) ? $values[$field['name']] : '';
$xml .= "\n<" . $field['name'] . '><![CDATA[' . $this->_escapeCdata($value) . ']]></' . $field['name'] . '>';
}
$xml .= "\n</" . $schema['root'] . '>';
libxml_use_internal_errors(true);
$check = new DOMDocument();
if (!$check->loadXML($xml))
{
libxml_clear_errors();
return error($this->ci->p->t('cms', 'xmlUngueltig'));
}
libxml_clear_errors();
return success($xml);
}
// -------------------------------------------------------------------------
// Private methods
/**
* Walks xs:element nodes recursively.
* The first element without a type attribute is the root.
* Elements with a type attribute are fields.
* xs:complexType and xs:sequence are traversed and ignored.
*/
private function _walkElements($node, &$root, &$fields)
{
if ($node->nodeType !== XML_ELEMENT_NODE)
{
return;
}
$localName = $node->localName;
if ($localName === 'element')
{
$typeAttr = $node->getAttribute('type');
if ($typeAttr === '')
{
if ($root === null)
{
$root = $node->getAttribute('name');
}
}
else
{
$minOccurs = $node->getAttribute('minOccurs');
$required = ($minOccurs === '') ? true : ((int)$minOccurs > 0);
$fields[] = array(
'name' => $node->getAttribute('name'),
'type' => $this->_mapType($typeAttr),
'required' => $required
);
}
}
foreach ($node->childNodes as $child)
{
$this->_walkElements($child, $root, $fields);
}
}
/**
* Maps an XSD type to the field schema type.
*/
private function _mapType($xsdType)
{
$map = array(
'xs:string' => 'string',
'xs:decimal' => 'string',
'xs:integer' => 'string',
'xs:positiveInteger' => 'string',
'xs:date' => 'date',
'wysiwyg' => 'wysiwyg',
'file' => 'file',
'boolean' => 'boolean'
);
return isset($map[$xsdType]) ? $map[$xsdType] : 'string';
}
// Escapes ]]> in the value. The legacy code does not do this and writes broken XML.
private function _escapeCdata($value)
{
return str_replace(']]>', ']]]]><![CDATA[>', $value);
}
}
@@ -323,4 +323,266 @@ class Content_model extends DB_Model
return success(isset($result[$root_content_id]) ? $result[$root_content_id] : null);
}
/**
* Root contents without a parent in tbl_contentchild. Excludes news.
* @return stdClass success with array of rows or error
*/
public function getRootContent()
{
$query = '
SELECT *
FROM (
SELECT DISTINCT ON (content_id) *
FROM campus.tbl_content
LEFT JOIN campus.tbl_contentchild USING (content_id)
WHERE tbl_content.template_kurzbz <> ?
AND content_id NOT IN (
SELECT child_content_id FROM campus.tbl_contentchild
WHERE child_content_id = tbl_content.content_id)
) AS a
ORDER BY sort, content_id
';
return $this->execReadOnlyQuery($query, ['news']);
}
/**
* Recent news from the last two months, max 100 rows.
* @return stdClass success with array of rows or error
*/
public function getNewsContent()
{
$query = "
SELECT *
FROM campus.tbl_content
JOIN campus.tbl_news USING (content_id)
WHERE tbl_news.datum >= now() - '2 month'::interval
ORDER BY datum DESC
LIMIT 100
";
return $this->execReadOnlyQuery($query);
}
/**
* Search contents by content_id or titel. Excludes news. Returns content_ids only.
* @param array $searchItems search terms
* @return stdClass success with array of rows or error
*/
public function searchCms($searchItems)
{
if (empty($searchItems))
return success([]);
$conditions = [];
$params = [];
foreach ($searchItems as $term)
{
$conditions[] = '(content_id::text = ? OR lower(titel) LIKE lower(?))';
$params[] = $term;
$params[] = '%' . $term . '%';
}
$query = '
SELECT DISTINCT tbl_content.content_id
FROM campus.tbl_contentsprache
JOIN campus.tbl_content USING (content_id)
WHERE tbl_content.template_kurzbz <> ?
AND (' . implode(' OR ', $conditions) . ')
ORDER BY content_id
';
array_unshift($params, 'news');
return $this->execReadOnlyQuery($query, $params);
}
/**
* Per content metadata for the tree filter. Excludes news, like getRootContent().
* updateamum is the last touch on the content or on any of its versions.
*
* contentlength counts the visible characters of the newest version, the way getOne()
* picks it: the default language first, then the highest version. The XML scaffolding,
* the CDATA markers, the HTML tags, the entities and every space drop out, so a content
* that nobody ever filled measures 0 whatever empty shape it carries. The title counts
* too, so a content with a title and no body measures short instead of zero.
*
* @param string $uid current user, decides the mine flag
* @param string $sprache preferred language of the measured version
* @return stdClass success with array of rows or error
*/
public function getTreeMeta($uid, $sprache)
{
$query = "
SELECT tbl_content.content_id,
(SELECT COUNT(*) FROM campus.tbl_contentchild
WHERE tbl_contentchild.content_id = tbl_content.content_id) AS childcount,
tbl_content.insertamum,
GREATEST(
tbl_content.updateamum,
(SELECT MAX(updateamum) FROM campus.tbl_contentsprache
WHERE tbl_contentsprache.content_id = tbl_content.content_id)
) AS updateamum,
COALESCE(
tbl_content.insertvon = ? OR tbl_content.updatevon = ? OR EXISTS (
SELECT 1 FROM campus.tbl_contentsprache
WHERE tbl_contentsprache.content_id = tbl_content.content_id
AND (tbl_contentsprache.insertvon = ?
OR tbl_contentsprache.updatevon = ?)
),
false
) AS mine,
(SELECT length(
regexp_replace(
regexp_replace(
regexp_replace(
replace(replace(cs.content::text, '<![CDATA[', ''), ']]>', ''),
'<[^>]*>', '', 'g'),
'&[a-zA-Z]+;|&#[0-9]+;', '', 'g'),
'\s+', '', 'g')
)
FROM campus.tbl_contentsprache cs
WHERE cs.content_id = tbl_content.content_id
ORDER BY (cs.sprache = ?) DESC, cs.version DESC
LIMIT 1) AS contentlength
FROM campus.tbl_content
WHERE tbl_content.template_kurzbz <> ?
";
return $this->execReadOnlyQuery(
$query, [$uid, $uid, $uid, $uid, $sprache, 'news']
);
}
/**
* All contents eligible as children: excludes ancestors, self, and news.
* @param int $content_id the content to find children for
* @param string $sprache language for the titel subselect
* @return stdClass success with array of rows or error
*/
public function getPossibleChilds($content_id, $sprache)
{
$query = '
SELECT content_id, oe_kurzbz, template_kurzbz,
(SELECT titel FROM campus.tbl_contentsprache
WHERE sprache = ? AND content_id = tbl_content.content_id
ORDER BY version LIMIT 1) AS titel
FROM campus.tbl_content
WHERE content_id NOT IN (
WITH RECURSIVE parents(content_id, child_content_id) AS (
SELECT content_id, child_content_id FROM campus.tbl_contentchild
WHERE child_content_id = ?
UNION ALL
SELECT cc.content_id, cc.child_content_id
FROM campus.tbl_contentchild cc, parents
WHERE cc.child_content_id = parents.content_id)
SELECT content_id FROM parents GROUP BY content_id)
AND content_id <> ?
AND template_kurzbz <> ?
ORDER BY titel
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id, $content_id, 'news']);
}
/**
* Where a content is referenced. Returns [{table, label}] for the delete dialog.
* Tolerates missing addon/testtool schemas.
* @param int $content_id
* @return stdClass success with flat array or error
*/
public function getUsage($content_id)
{
$coreQuery = "
SELECT 'campus.tbl_infoscreen_content' AS \"table\",
infoscreen_id::text AS label
FROM campus.tbl_infoscreen_content WHERE content_id = ?
UNION ALL
SELECT 'campus.tbl_news', betreff
FROM campus.tbl_news WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_ort', ort_kurzbz
FROM public.tbl_ort WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_service', bezeichnung
FROM public.tbl_service WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_statistik', bezeichnung
FROM public.tbl_statistik WHERE content_id = ?
";
$coreParams = [$content_id, $content_id, $content_id, $content_id, $content_id];
$coreResult = $this->execReadOnlyQuery($coreQuery, $coreParams);
$rows = [];
if (!isError($coreResult) && getData($coreResult))
$rows = getData($coreResult);
// Optional schemas: testtool and addon may not exist. A failing query still writes a
// db error into the response envelope, so check for the table before querying it.
$optionalQueries = [
[
'testtool', 'tbl_ablauf_vorgaben',
"SELECT 'testtool.tbl_ablauf_vorgaben' AS \"table\",
ablauf_vorgaben_id::text AS label
FROM testtool.tbl_ablauf_vorgaben WHERE content_id = ?"
],
[
'addon', 'tbl_software',
"SELECT 'addon.tbl_software' AS \"table\",
software_id::text AS label
FROM addon.tbl_software WHERE content_id = ?"
]
];
foreach ($optionalQueries as $opt)
{
if (!$this->tableExists($opt[0], $opt[1]))
continue;
$result = $this->execReadOnlyQuery($opt[2], [$content_id]);
if (!isError($result) && getData($result))
$rows = array_merge($rows, getData($result));
}
return success($rows);
}
/**
* True if a base table exists. Guards the optional testtool and addon schemas.
* @param string $schema
* @param string $table
* @return bool
*/
private function tableExists($schema, $table)
{
$query = "
SELECT 1 AS exists
FROM information_schema.tables
WHERE table_catalog = ? AND table_schema = ? AND table_name = ?
";
return hasData($this->execReadOnlyQuery($query, [DB_NAME, $schema, $table]));
}
/**
* Returns oe_kurzbz for a content as a string.
* @param int $content_id
* @return stdClass success with string or error
*/
public function getOeKurzbz($content_id)
{
$query = 'SELECT oe_kurzbz FROM campus.tbl_content WHERE content_id = ?';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$data = getData($result);
if (empty($data))
return error('Content not found');
return success($data[0]->oe_kurzbz);
}
}
@@ -1,6 +1,9 @@
<?php
class Contentchild_model extends DB_Model
{
// swapSort reports the two boundary cases with this. See swapSort().
const NO_NEIGHBOUR = 'no neighbour';
/**
* Constructor
@@ -11,4 +14,345 @@ class Contentchild_model extends DB_Model
$this->dbTable = 'campus.tbl_contentchild';
$this->pk = 'contentchild_id';
}
/**
* Children with titel, sorted by sort.
* @param int $content_id
* @param string $sprache
* @return stdClass success with array of rows or error
*/
public function getChilds($content_id, $sprache)
{
$query = '
SELECT cc.contentchild_id, cc.child_content_id, cc.sort,
(SELECT titel FROM campus.tbl_contentsprache
WHERE content_id = cc.child_content_id AND sprache = ?
ORDER BY version LIMIT 1) AS titel
FROM campus.tbl_contentchild cc
WHERE cc.content_id = ?
ORDER BY cc.sort, cc.contentchild_id
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id]);
}
/**
* Parents of a content, with the title of each parent.
* A content can hang under more than one parent, so this returns a list.
* @param int $content_id
* @param string $sprache
* @return stdClass success with array of rows or error
*/
public function getParents($content_id, $sprache)
{
$query = '
SELECT cc.contentchild_id, cc.content_id, cc.sort,
(SELECT titel FROM campus.tbl_contentsprache
WHERE content_id = cc.content_id AND sprache = ?
ORDER BY version LIMIT 1) AS titel
FROM campus.tbl_contentchild cc
WHERE cc.child_content_id = ?
ORDER BY cc.content_id
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id]);
}
/**
* Children of a content as a reader may see them.
*
* Differs from getChilds() on purpose. getChilds() serves the admin and returns every
* child with the lowest version. A reader needs the newest visible version, only active
* contents, and no page that a group rule keeps from them.
*
* The group rule follows CmsLib::getContent(): a content without a row in
* tbl_contentgruppe is open, a content with rows needs membership in one of them.
*
* @param int $content_id
* @param string $sprache
* @param string $uid
* @return stdClass success with array of rows or error
*/
public function getChildsForReader($content_id, $sprache, $uid)
{
$query = '
SELECT
cc.child_content_id AS content_id,
cc.sort,
COALESCE(
(SELECT cs.titel FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id
AND cs.sprache = ?
AND cs.sichtbar
AND LENGTH(cs.titel) > 0
ORDER BY cs.version DESC NULLS LAST LIMIT 1),
(SELECT cs.titel FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id
AND cs.sichtbar
AND LENGTH(cs.titel) > 0
ORDER BY cs.version DESC NULLS LAST LIMIT 1),
c.beschreibung
) AS titel
FROM campus.tbl_contentchild cc
JOIN campus.tbl_content c ON c.content_id = cc.child_content_id
WHERE cc.content_id = ?
AND c.aktiv
-- Without a visible version the reader cannot open the page at all.
AND EXISTS (
SELECT 1 FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id AND cs.sichtbar
)
AND (
NOT EXISTS (
SELECT 1 FROM campus.tbl_contentgruppe cg
WHERE cg.content_id = cc.child_content_id
)
OR EXISTS (
SELECT 1 FROM campus.tbl_contentgruppe cg
JOIN public.vw_gruppen vg ON vg.gruppe_kurzbz = cg.gruppe_kurzbz
WHERE cg.content_id = cc.child_content_id AND vg.uid = ?
)
)
ORDER BY cc.sort, cc.contentchild_id
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id, $uid]);
}
/**
* Highest sort value. Returns 0 if no row exists.
* @param int $content_id
* @return stdClass success with int or error
*/
public function getMaxSort($content_id)
{
$query = '
SELECT COALESCE(MAX(sort), 0) AS max_sort
FROM campus.tbl_contentchild
WHERE content_id = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->max_sort);
}
/**
* All descendant content_ids recursively. Returns a flat array.
* @param int $content_id
* @return stdClass success with array of ints or error
*/
public function getAllChildIds($content_id)
{
$query = '
WITH RECURSIVE childs(content_id, child_content_id) AS (
SELECT content_id, child_content_id FROM campus.tbl_contentchild WHERE content_id = ?
UNION ALL
SELECT cc.content_id, cc.child_content_id
FROM campus.tbl_contentchild cc, childs
WHERE cc.content_id = childs.child_content_id)
SELECT DISTINCT child_content_id FROM childs
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$ids = [];
if (getData($result))
{
foreach (getData($result) as $row)
$ids[] = (int) $row->child_content_id;
}
return success($ids);
}
/**
* Swap sort value with the neighbour. Direction is 'up' or 'down'.
* @param int $contentchild_id
* @param string $direction 'up' or 'down'
* @return stdClass success or error
*/
public function swapSort($contentchild_id, $direction)
{
$currentResult = $this->load($contentchild_id);
if (isError($currentResult))
return $currentResult;
$current = getData($currentResult)[0];
if ($direction === 'up')
{
$neighbourQuery = '
SELECT contentchild_id, sort
FROM campus.tbl_contentchild
WHERE content_id = ? AND sort < ?
ORDER BY sort DESC
LIMIT 1
';
}
else
{
$neighbourQuery = '
SELECT contentchild_id, sort
FROM campus.tbl_contentchild
WHERE content_id = ? AND sort > ?
ORDER BY sort ASC
LIMIT 1
';
}
$neighbourResult = $this->execReadOnlyQuery($neighbourQuery, [$current->content_id, $current->sort]);
if (isError($neighbourResult))
return $neighbourResult;
$neighbourData = getData($neighbourResult);
// A marker, not a message. The controller owns the wording, because a model has no
// phrases. Returning a phrase key here put the key itself in front of the user.
if (empty($neighbourData))
return error(self::NO_NEIGHBOUR);
$neighbour = $neighbourData[0];
// Transaction wraps the swap; the legacy does not.
$this->db->trans_start();
$this->execQuery(
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
[$neighbour->sort, $current->contentchild_id]
);
$this->execQuery(
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
[$current->sort, $neighbour->contentchild_id]
);
$this->db->trans_complete();
if ($this->db->trans_status() === false)
return error('Sort swap failed');
return success(true);
}
/**
* Writes the whole order of one content in one transaction. Serves the drag and drop.
* The list must hold every child of the content exactly once, or the rows left out
* would keep a stale sort value.
* @param int $content_id
* @param array $contentchild_ids in the wanted order
* @return stdClass success(true) or error
*/
public function setSortOrder($content_id, $contentchild_ids)
{
$currentResult = $this->execReadOnlyQuery(
'SELECT contentchild_id FROM campus.tbl_contentchild WHERE content_id = ?',
[$content_id]
);
if (isError($currentResult))
return $currentResult;
$current = [];
foreach ((array) getData($currentResult) as $row)
$current[] = (int) $row->contentchild_id;
$wanted = array_map('intval', array_values($contentchild_ids));
$check = $wanted;
sort($current);
sort($check);
if ($current !== $check)
return error('Sort order does not match the children of the content');
$this->db->trans_start();
foreach ($wanted as $position => $contentchild_id)
{
$this->execQuery(
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
[$position + 1, $contentchild_id]
);
}
$this->db->trans_complete();
if ($this->db->trans_status() === false)
return error('Sort order update failed');
return success(true);
}
/**
* Every ancestor of the given contents, with the distance to the content it belongs to.
*
* The tree shows a search result out of its place: a title like "Team" says nothing
* about which unit it belongs to. This delivers the way down to it.
*
* A content can hang under more than one parent, so a level can hold more than one
* ancestor. Every one of them comes back, with tiefe 1 for the direct parent. The
* caller decides how to draw that.
*
* tiefe also ends a cycle: a content whose parent chain leads back to itself would
* otherwise recurse forever.
*
* @param array $content_ids contents to walk up from
* @param int $maxTiefe how many levels to climb
* @param string $sprache language of the ancestor titles
* @return stdClass success with array of rows or error
*/
public function getAncestors($content_ids, $maxTiefe, $sprache)
{
if (empty($content_ids))
return success([]);
$platzhalter = implode(', ', array_fill(0, count($content_ids), '?'));
// The aggregate and the title sit in the final select. The recursive term names
// the working table once and carries no subquery, which is what Postgres allows.
$query = '
WITH RECURSIVE auf(start_id, content_id, tiefe) AS (
SELECT cc.child_content_id, cc.content_id, 1
FROM campus.tbl_contentchild cc
WHERE cc.child_content_id IN (' . $platzhalter . ')
UNION ALL
SELECT auf.start_id, cc.content_id, auf.tiefe + 1
FROM campus.tbl_contentchild cc
JOIN auf ON cc.child_content_id = auf.content_id
WHERE auf.tiefe < ?
)
SELECT auf.start_id, auf.content_id, MIN(auf.tiefe) AS tiefe,
(SELECT titel FROM campus.tbl_contentsprache cs
WHERE cs.content_id = auf.content_id AND cs.sprache = ?
ORDER BY cs.version LIMIT 1) AS titel,
-- The caller turns this into the entitled flag, so the path can offer a
-- link only for an ancestor the editor may open.
(SELECT c.oe_kurzbz FROM campus.tbl_content c
WHERE c.content_id = auf.content_id) AS oe_kurzbz
FROM auf
GROUP BY auf.start_id, auf.content_id
ORDER BY auf.start_id, MIN(auf.tiefe) DESC, auf.content_id
';
$params = array_map('intval', array_values($content_ids));
$params[] = (int) $maxTiefe;
$params[] = $sprache;
return $this->execReadOnlyQuery($query, $params);
}
/**
* Removes a content from the child table, as parent and as child.
* @param int $content_id
* @return stdClass
*/
public function deleteByContent($content_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentchild
WHERE content_id = ? OR child_content_id = ?',
[$content_id, $content_id]
);
}
}
@@ -10,6 +10,8 @@ class Contentgruppe_model extends DB_Model
parent::__construct();
$this->dbTable = 'campus.tbl_contentgruppe';
$this->pk = array('gruppe_kurzbz', 'content_id');
// Composite key without a sequence, so insert() must skip insert_id() and its LASTVAL().
$this->hasSequence = false;
}
/**
@@ -18,7 +20,7 @@ class Contentgruppe_model extends DB_Model
*
* @param int $content_id
*
* @return stdClass success(true) wenn eingeschraenkt sonst success(false)
* @return stdClass success(true) wenn locked sonst success(false)
*/
public function islocked($content_id)
{
@@ -30,7 +32,7 @@ class Contentgruppe_model extends DB_Model
}
/**
* Prueft ob ein User die Berechtigung fuer das Anzeigen des
* Prüft ob ein User die Berechtigung fuer das Anzeigen des
* Contents besitzt
*
* @param int $content_id ID des Contents
@@ -56,5 +58,89 @@ class Contentgruppe_model extends DB_Model
return $result;
return success(!!getData($result));
}
/**
* Groups assigned to a content, with bezeichnung.
* @param int $content_id
* @return stdClass success with array of rows or error
*/
public function getGruppen($content_id)
{
$query = '
SELECT cg.gruppe_kurzbz, g.bezeichnung
FROM campus.tbl_contentgruppe cg
JOIN public.tbl_gruppe g USING (gruppe_kurzbz)
WHERE cg.content_id = ?
ORDER BY cg.gruppe_kurzbz
';
return $this->execReadOnlyQuery($query, [$content_id]);
}
/**
* All selectable groups. Returns gruppe_kurzbz and bezeichnung.
* @return stdClass success with array of rows or error
*/
public function getAllGruppen()
{
// content_visible marks a group as selectable for content rights. Generated groups
// carry FALSE. admin.php filters the same way via getgruppe(...,$content_visible=true).
$query = '
SELECT gruppe_kurzbz, bezeichnung
FROM public.tbl_gruppe
WHERE content_visible = TRUE
ORDER BY gruppe_kurzbz
';
return $this->execReadOnlyQuery($query);
}
/**
* Groups for multiple contents in one query. Returns content_id => [gruppe_kurzbz, ...].
* @param array $contentIds array of content_id values
* @return stdClass success with associative array or error
*/
public function getGruppenForContents($contentIds)
{
if (empty($contentIds))
return success([]);
$placeholders = implode(',', array_fill(0, count($contentIds), '?'));
$query = '
SELECT content_id, gruppe_kurzbz
FROM campus.tbl_contentgruppe
WHERE content_id IN (' . $placeholders . ')
ORDER BY content_id, gruppe_kurzbz
';
$result = $this->execReadOnlyQuery($query, array_values($contentIds));
if (isError($result))
return $result;
$map = [];
if (getData($result))
{
foreach (getData($result) as $row)
{
$id = (int) $row->content_id;
if (!isset($map[$id]))
$map[$id] = [];
$map[$id][] = $row->gruppe_kurzbz;
}
}
return success($map);
}
/**
* @param int $content_id
* @return stdClass
*/
public function deleteByContent($content_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentgruppe WHERE content_id = ?',
[$content_id]
);
}
}
@@ -11,4 +11,76 @@ class Contentlog_model extends DB_Model
$this->dbTable = 'campus.tbl_contentlog';
$this->pk = 'contentlog_id';
}
/**
* The open lock entry of a user (ende IS NULL). Returns null if none exists.
* Match the holder and take the newest, otherwise the age belongs to an abandoned entry.
* @param int $contentsprache_id
* @param string $uid holder of the lock
* @return stdClass success with row or null, or error
*/
public function getOpenLock($contentsprache_id, $uid)
{
$query = '
SELECT contentlog_id, uid, contentsprache_id, start
FROM campus.tbl_contentlog
WHERE contentsprache_id = ? AND uid = ? AND ende IS NULL
ORDER BY start DESC
LIMIT 1
';
$result = $this->execReadOnlyQuery($query, [$contentsprache_id, $uid]);
if (isError($result))
return $result;
$data = getData($result);
return success(empty($data) ? null : $data[0]);
}
/**
* Ends every open lock entry of a version. Without a uid it ends the entry of every
* holder, which is what a take-over and a forced release need.
* @param int $contentsprache_id
* @param string|null $uid
* @return stdClass
*/
public function closeOpenEntries($contentsprache_id, $uid = null)
{
$query = 'UPDATE campus.tbl_contentlog SET ende = now()
WHERE contentsprache_id = ? AND ende IS NULL';
$params = [$contentsprache_id];
if ($uid !== null)
{
$query .= ' AND uid = ?';
$params[] = $uid;
}
return $this->execQuery($query, $params);
}
/**
* @param int $contentsprache_id
* @return stdClass
*/
public function deleteByContentsprache($contentsprache_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id = ?',
[$contentsprache_id]
);
}
/**
* @param int $content_id
* @return stdClass
*/
public function deleteByContent($content_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id IN
(SELECT contentsprache_id FROM campus.tbl_contentsprache WHERE content_id = ?)',
[$content_id]
);
}
}
@@ -38,5 +38,177 @@ class Contentsprache_model extends DB_Model
return success(!!getData($result));
}
/**
* All versions of a language, ascending by version.
* @param int $content_id
* @param string $sprache
* @return stdClass success with array of rows or error
*/
public function getVersions($content_id, $sprache)
{
$query = '
SELECT contentsprache_id, version, sichtbar, titel,
insertamum, insertvon, updateamum, updatevon, gesperrt_uid
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
ORDER BY version ASC
';
return $this->execReadOnlyQuery($query, [$content_id, $sprache]);
}
/**
* Distinct languages for a content. Returns a flat array of strings.
* @param int $content_id
* @return stdClass success with array of strings or error
*/
public function getLanguages($content_id)
{
$query = '
SELECT DISTINCT sprache
FROM campus.tbl_contentsprache
WHERE content_id = ?
ORDER BY sprache
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$languages = [];
if (getData($result))
{
foreach (getData($result) as $row)
$languages[] = $row->sprache;
}
return success($languages);
}
/**
* Highest version number. Returns 0 if no row exists.
* @param int $content_id
* @param string $sprache
* @return stdClass success with int or error
*/
public function getMaxVersion($content_id, $sprache)
{
$query = '
SELECT COALESCE(MAX(version), 0) AS max_version
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id, $sprache]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->max_version);
}
/**
* Row count for a content+language. Used for the delete guard.
* @param int $content_id
* @param string $sprache
* @return stdClass success with int or error
*/
public function getNumberOfVersions($content_id, $sprache)
{
$query = '
SELECT COUNT(*) AS cnt
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id, $sprache]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->cnt);
}
/**
* One version, all columns. If $version is empty, returns the highest version.
* @param int $content_id
* @param string $sprache
* @param int|null $version
* @return stdClass success with single row or error
*/
public function getOne($content_id, $sprache, $version = null)
{
$params = [$content_id, $sprache];
$versionClause = '';
if (!empty($version))
{
$versionClause = ' AND version = ?';
$params[] = $version;
}
$query = '
SELECT * FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?' . $versionClause . '
ORDER BY version DESC LIMIT 1
';
$result = $this->execReadOnlyQuery($query, $params);
if (isError($result))
return $result;
$data = getData($result);
if (empty($data))
return error('Version not found');
return success($data[0]);
}
/**
* Clears the lock of a version. Without a uid it clears whoever holds it, which is
* what a forced release needs.
* @param int $contentsprache_id
* @param string|null $uid
* @return stdClass
*/
public function releaseLock($contentsprache_id, $uid = null)
{
$query = 'UPDATE campus.tbl_contentsprache SET gesperrt_uid = NULL
WHERE contentsprache_id = ?';
$params = [$contentsprache_id];
if ($uid !== null)
{
$query .= ' AND gesperrt_uid = ?';
$params[] = $uid;
}
return $this->execQuery($query, $params);
}
/**
* @param int $content_id
* @return stdClass
*/
public function deleteByContent($content_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentsprache WHERE content_id = ?',
[$content_id]
);
}
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @return stdClass
*/
public function deleteVersion($content_id, $sprache, $version)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ? AND version = ?',
[$content_id, $sprache, $version]
);
}
}
+237
View File
@@ -30,4 +30,241 @@ class Dms_model extends DB_Model
return $returnArray;
}
/**
* Reads a DMS category and the access facts a listing needs.
*
* Mirrors the rule of cms/dms.php and dms.class.php at category level:
* a category is locked when any category in its parent chain grants a group or
* carries a berechtigung_kurzbz. The caller decides, this method only reports.
*
* @param string $kategorie_kurzbz
* gesperrt and in_gruppe come back as 0 or 1, not as a boolean. A PostgreSQL boolean
* reaches PHP as the string 'f' when the driver conversion does not run, and 'f' is
* truthy. The int cast is correct in both cases.
*
* @param string $uid
* @return object one row, or an empty result when the category does not exist
*/
public function getKategorieZugriff($kategorie_kurzbz, $uid)
{
// tiefe guards against a cyclic parent chain.
$query = '
WITH RECURSIVE chain(kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, tiefe) AS (
SELECT kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, 1
FROM campus.tbl_dms_kategorie
WHERE kategorie_kurzbz = ?
UNION ALL
SELECT k.kategorie_kurzbz, k.parent_kategorie_kurzbz, k.berechtigung_kurzbz, c.tiefe + 1
FROM campus.tbl_dms_kategorie k
JOIN chain c ON k.kategorie_kurzbz = c.parent_kategorie_kurzbz
WHERE c.tiefe < 32
)
SELECT
kat.kategorie_kurzbz,
kat.bezeichnung,
kat.beschreibung,
kat.berechtigung_kurzbz,
(
EXISTS (SELECT 1 FROM chain WHERE chain.berechtigung_kurzbz IS NOT NULL)
OR EXISTS (
SELECT 1
FROM campus.tbl_dms_kategorie_gruppe g
JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz
)
)::int AS gesperrt,
(EXISTS (
SELECT 1
FROM campus.tbl_dms_kategorie_gruppe g
JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz
JOIN public.tbl_benutzergruppe b ON b.gruppe_kurzbz = g.gruppe_kurzbz
WHERE b.uid = ?
))::int AS in_gruppe
FROM campus.tbl_dms_kategorie kat
WHERE kat.kategorie_kurzbz = ?
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz, $uid, $kategorie_kurzbz));
}
/**
* Lists the newest version of every document in one DMS category.
*
* filename stays out of the result. The reader gets the document through
* cms/dms.php?id=<dms_id>, never through the name on disk.
*
* @param string $kategorie_kurzbz
* @return object
*/
public function getKategorieDokumente($kategorie_kurzbz)
{
$query = '
SELECT
d.dms_id,
v.version,
v.name,
v.beschreibung,
v.mimetype,
COALESCE(v.updateamum, v.insertamum) AS geaendert
FROM campus.tbl_dms d
JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id
WHERE d.kategorie_kurzbz = ?
-- cis_suche marks a document as published to CIS readers. A category also
-- holds working files, and those must not appear on a content page.
AND v.cis_suche
AND v.version = (
SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id
)
-- A project document carries its own rule in dms.class.php and does not
-- belong in a CMS category listing.
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
ORDER BY LOWER(v.name)
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz));
}
/**
* Newest version of the named documents, in no particular order.
*
* Unlike getKategorieDokumente() this does NOT filter on cis_suche. An editor who names
* a dms_id has chosen that document on purpose, and the flag only marks a document for
* the CIS search. The category rule still applies, and the caller enforces it.
*
* @param array $ids dms_id values
* @return object
*/
public function getDokumenteByIds($ids)
{
if (empty($ids))
return success(array());
$platzhalter = implode(', ', array_fill(0, count($ids), '?'));
$query = '
SELECT
d.dms_id,
d.kategorie_kurzbz,
v.version,
v.name,
v.beschreibung,
v.mimetype,
COALESCE(v.updateamum, v.insertamum) AS geaendert
FROM campus.tbl_dms d
JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id
WHERE d.dms_id IN (' . $platzhalter . ')
AND v.version = (
SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id
)
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
';
return $this->execReadOnlyQuery($query, array_values($ids));
}
/**
* Every document of one category, for the editor picker.
*
* Unlike getKategorieDokumente() this does NOT filter on cis_suche, because
* dms-dokumente renders named documents regardless of that flag. The flag travels with
* the row so the picker can mark a document that no reader would find by search.
*
* @param string $kategorie_kurzbz
* @return object
*/
public function getKategorieDokumenteAlle($kategorie_kurzbz)
{
$query = '
SELECT
d.dms_id,
v.name,
v.cis_suche::int AS cis_suche
FROM campus.tbl_dms d
JOIN LATERAL (
SELECT v.name, v.cis_suche
FROM campus.tbl_dms_version v
WHERE v.dms_id = d.dms_id
ORDER BY v.version DESC
LIMIT 1
) v ON TRUE
WHERE d.kategorie_kurzbz = ?
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
ORDER BY LOWER(v.name)
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz));
}
/**
* Lists every DMS category with the number of documents it holds.
* Used by the insert dialog of the CMS content component "dms-liste".
*
* @return object
*/
public function getKategorien()
{
$query = '
SELECT
k.kategorie_kurzbz,
k.bezeichnung,
k.oe_kurzbz,
COALESCE(a.anzahl, 0) AS anzahl
FROM campus.tbl_dms_kategorie k
LEFT JOIN (
SELECT d.kategorie_kurzbz, COUNT(*) AS anzahl
FROM campus.tbl_dms d
JOIN (
SELECT DISTINCT ON (dms_id) dms_id, cis_suche
FROM campus.tbl_dms_version
ORDER BY dms_id, version DESC
) v ON v.dms_id = d.dms_id
WHERE v.cis_suche
GROUP BY d.kategorie_kurzbz
) a ON a.kategorie_kurzbz = k.kategorie_kurzbz
ORDER BY LOWER(COALESCE(k.bezeichnung, k.kategorie_kurzbz))
';
return $this->execReadOnlyQuery($query);
}
/**
* The category itself plus its sub categories, down to a depth.
*
* tiefe 0 returns only the category named. The depth bound also ends a cycle: a
* category whose parent chain leads back to itself would otherwise recurse forever.
*
* The caller must still check the entitlement of every category it gets back. A sub
* category carries its own group rule and may be closed while the parent is open.
*
* @param string $kategorie_kurzbz root of the walk
* @param int $tiefe how many levels below the root to include
* @return object success with array of rows or error
*/
public function getMitUnterkategorien($kategorie_kurzbz, $tiefe)
{
$query = '
WITH RECURSIVE unter(kategorie_kurzbz, tiefe) AS (
SELECT kategorie_kurzbz, 0
FROM campus.tbl_dms_kategorie
WHERE kategorie_kurzbz = ?
UNION ALL
SELECT k.kategorie_kurzbz, unter.tiefe + 1
FROM campus.tbl_dms_kategorie k
JOIN unter ON k.parent_kategorie_kurzbz = unter.kategorie_kurzbz
WHERE unter.tiefe < ?
)
SELECT kategorie_kurzbz, MIN(tiefe) AS tiefe
FROM unter
GROUP BY kategorie_kurzbz
ORDER BY MIN(tiefe), kategorie_kurzbz
';
return $this->execReadOnlyQuery($query, [$kategorie_kurzbz, (int) $tiefe]);
}
}
@@ -11,4 +11,30 @@ class Template_model extends DB_Model
$this->dbTable = 'campus.tbl_template';
$this->pk = 'template_kurzbz';
}
/**
* True if the template carries a CIS4 stylesheet of its own, which is not true for every template.
*
* @param string $template_kurzbz
* @return stdClass success(bool) or error
*/
public function hasCis4Stylesheet($template_kurzbz)
{
$query = '
SELECT (xslt_xhtml_c4 IS NOT NULL
AND xslt_xhtml_c4::text IS DISTINCT FROM xslt_xhtml::text) AS eigen
FROM campus.tbl_template
WHERE template_kurzbz = ?
';
$result = $this->execReadOnlyQuery($query, [$template_kurzbz]);
if (isError($result))
return $result;
$data = getData($result);
if (empty($data))
return success(false);
return success($data[0]->eigen === 't' || $data[0]->eigen === true);
}
}
+1 -5
View File
@@ -15,11 +15,7 @@ class Note_model extends DB_Model
public function getAllActive() {
$qry ="SELECT *
FROM lehre.tbl_note
WHERE aktiv = true
ORDER BY CASE WHEN note BETWEEN 1 AND 5 THEN 0 ELSE 1 END,
CASE WHEN note BETWEEN 1 AND 5 THEN note END,
bezeichnung";
WHERE aktiv = true";
return $this->execReadOnlyQuery($qry);
}
@@ -259,4 +259,37 @@ class Organisationseinheit_model extends DB_Model
return $this->execReadOnlyQuery($qry, array($oe_kurzbz));
}
/**
* The unit itself plus its sub units, down to a depth.
*
* tiefe 0 returns only the unit named, which is what a caller without a depth wants.
* The depth bound also ends a cycle: a unit whose parent chain leads back to itself
* would otherwise recurse forever.
*
* @param string $oe_kurzbz root of the walk
* @param int $tiefe how many levels below the root to include
* @return object success with array of rows or error
*/
public function getMitUntereinheiten($oe_kurzbz, $tiefe)
{
$query = '
WITH RECURSIVE unter(oe_kurzbz, tiefe) AS (
SELECT oe_kurzbz, 0
FROM public.tbl_organisationseinheit
WHERE oe_kurzbz = ?
UNION ALL
SELECT o.oe_kurzbz, unter.tiefe + 1
FROM public.tbl_organisationseinheit o
JOIN unter ON o.oe_parent_kurzbz = unter.oe_kurzbz
WHERE unter.tiefe < ?
)
SELECT oe_kurzbz, MIN(tiefe) AS tiefe
FROM unter
GROUP BY oe_kurzbz
ORDER BY MIN(tiefe), oe_kurzbz
';
return $this->execReadOnlyQuery($query, [$oe_kurzbz, (int) $tiefe]);
}
}
@@ -243,6 +243,58 @@ class Person_model extends DB_Model
* @param $uid
* @return array
*/
/**
* Contact data of the named users, for the CMS content components.
*
* The joins follow Benutzerfunktion_model::getBenutzerFunktionenDetailed() and the view
* Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the same everywhere.
*
* foto_sperre is honoured. A person who blocked their photo never gets one on a public
* content page, no matter what the page asks for.
*
* @param array $uids
* @param boolean $mitFoto photos are large, so they are read only on request
* @return object
*/
public function getCmsKontakt($uids, $mitFoto = false)
{
if (empty($uids))
return success(array());
$platzhalter = implode(', ', array_fill(0, count($uids), '?'));
$fotoSpalte = $mitFoto
? 'CASE WHEN p.foto_sperre THEN NULL ELSE p.foto END'
: 'NULL';
$query = '
SELECT
b.uid,
b.alias,
p.vorname,
p.nachname,
p.titelpre,
p.titelpost,
m.telefonklappe,
k.kontakt,
o.planbezeichnung,
' . $fotoSpalte . ' AS foto
FROM public.tbl_benutzer b
JOIN public.tbl_person p ON p.person_id = b.person_id
LEFT JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid
LEFT JOIN public.tbl_kontakt k
ON k.standort_id = m.standort_id AND k.kontakttyp = ?
LEFT JOIN public.tbl_ort o ON o.ort_kurzbz = m.ort_kurzbz
WHERE b.uid IN (' . $platzhalter . ')
AND b.aktiv
';
$parameter = array('telefon');
foreach ($uids as $uid)
$parameter[] = $uid;
return $this->execReadOnlyQuery($query, $parameter);
}
public function getByUid($uid)
{
$this->addSelect('vorname, nachname, gebdatum, person_id, bpk, matr_nr, foto');
@@ -98,6 +98,31 @@ class Mitarbeiter_model extends DB_Model
return $this->execQuery($qry, $params);
}
/**
* Active staff, names only, for a picker.
*
* getPersonal() returns tbl_person.* including svnr, bpk and
* gebdatum, which is not needed in a CMS endpoint.
*
* @return object
*/
public function getAktivesPersonalNamen()
{
$query = '
SELECT
b.uid,
p.vorname,
p.nachname
FROM public.tbl_benutzer b
JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid
JOIN public.tbl_person p ON p.person_id = b.person_id
WHERE b.aktiv
ORDER BY LOWER(p.nachname), LOWER(p.vorname)
';
return $this->execReadOnlyQuery($query);
}
/**
* gibt Personen mit Übersicht von Vertragsdaten aus
*
@@ -12,4 +12,72 @@ class Webservicelog_model extends DB_Model
$this->dbTable = 'system.tbl_webservicelog';
$this->pk = 'webservicelog_id';
}
/**
* The most viewed contents since a date, with the title of each one.
* request_id is a varchar, so the join casts the content_id to text. A cast of
* request_id to a number would fail on a row that holds no number.
* The join also drops a log row whose content is deleted.
* @param string|null $since timestamp in the form Y-m-d H:i:s, null for the whole log
* @param string $sprache language of the title
* @param int $limit length of the ranking
* @return stdClass success with array of rows or error
*/
public function getContentClickCounts($since, $sprache, $limit)
{
$params = [$sprache, 'content'];
$sinceCondition = '';
if ($since !== null)
{
$sinceCondition = ' AND w.execute_time >= ?';
$params[] = $since;
}
$params[] = $limit;
$query = '
SELECT c.content_id, COUNT(*) AS hits,
(SELECT titel FROM campus.tbl_contentsprache
WHERE content_id = c.content_id AND sprache = ?
ORDER BY version LIMIT 1) AS titel
FROM system.tbl_webservicelog w
JOIN campus.tbl_content c ON c.content_id::text = w.request_id
WHERE w.webservicetyp_kurzbz = ?' . $sinceCondition . '
GROUP BY c.content_id
ORDER BY COUNT(*) DESC, c.content_id
LIMIT ?
';
return $this->execReadOnlyQuery($query, $params);
}
/**
* Views of one content since a date. A content outside the ranking still needs its
* own number.
* @param int $content_id
* @param string|null $since timestamp in the form Y-m-d H:i:s, null for the whole log
* @return stdClass success with int or error
*/
public function getContentClickCount($content_id, $since)
{
$query = '
SELECT COUNT(*) AS hits
FROM system.tbl_webservicelog
WHERE webservicetyp_kurzbz = ? AND request_id = ?
';
$params = ['content', (string) $content_id];
if ($since !== null)
{
$query .= ' AND execute_time >= ?';
$params[] = $since;
}
$result = $this->execReadOnlyQuery($query, $params);
if (isError($result))
return $result;
return success((int) getData($result)[0]->hits);
}
}
@@ -23,6 +23,7 @@ $includesArray = array(
'public/css/components/FormUnderline.css',
'public/css/components/abgabetool/abgabe.css',
'public/css/Cis4/Cms.css',
'public/css/Cis4/Contentcomponents.css',
'public/css/Cis4/Studium.css'
),
'customJSs' => array(
+35
View File
@@ -0,0 +1,35 @@
<?php
$includesArray = array(
'title' => 'CMS',
'axios027' => true,
'bootstrap5' => true,
'fontawesome6' => true,
'vue3' => true,
'primevue3' => true,
'tabulator6' => true,
'tinymce5' => true,
'phrases' => array('global', 'ui', 'cms'),
'customCSSs' => [
'public/css/components/verticalsplit.css',
'public/css/components/horizontalsplit.css',
'public/css/Cms.css'
],
'customJSs' => [
'vendor/npm-asset/primevue/tree/tree.min.js',
'vendor/npm-asset/primevue/checkbox/checkbox.min.js',
'vendor/npm-asset/primevue/textarea/textarea.min.js',
'vendor/moment/luxonjs/luxon.min.js'
],
'customJSModules' => ['public/js/apps/Cms.js']
);
$this->load->view('templates/FHC-Header', $includesArray);
?>
<div id="main">
<router-view
cms-root="<?= site_url('cms'); ?>"
auth-uid="<?= getAuthUID(); ?>"
default-language="<?= defined('DEFAULT_LANGUAGE') ? DEFAULT_LANGUAGE : 'German'; ?>"
<?= !empty($clickstats) ? 'clickstats' : ''; ?>
></router-view>
</div>
<?php $this->load->view('templates/FHC-Footer', $includesArray); ?>
+28
View File
@@ -83,12 +83,40 @@ if (! $rechte->isberechtigt('basis/dms', null, 's', null))
return confirm('Möchten Sie das File wirklich löschen?');
}
/*
* CIS4 bridge, ticket 78489.
*
* The TinyMCE 5 editor of the new CMS admin cannot receive a selection through the
* TinyMCE 3 API below, so this window posts the URL back to its opener instead.
*
* message: { typ: 'fhc-dms-selection', url: 'dms.php?id=<dms_id>' }
* target: window.opener, origin checked on both sides
*
* The path is chosen by window.name, which the opener sets and which survives the
* internal navigation of this browser.
*
* Receiving side: public/js/components/Cms/Form/FieldWysiwyg.js
*/
var FileBrowserDialog=
{
init: function(){
},
mySubmit : function (id) {
var URL = "dms.php?id="+id;
// CIS4 path: see the comment block above this function. Do not test for
// tinyMCEPopup, this page loads tiny_mce_popup.js itself and always defines it.
if (window.name === 'DMS_CIS4' && window.opener && !window.opener.closed) {
window.opener.postMessage({ typ: 'fhc-dms-selection', url: URL }, window.location.origin);
window.close();
return;
}
if (typeof tinyMCEPopup === 'undefined') {
alert('The calling window is no longer available.');
return;
}
var win = tinyMCEPopup.getWindowArg("window");
// insert information now
+3
View File
@@ -840,8 +840,11 @@ html {
color: var(--fhc-light);
}
/* the bootstrap opacity 0.5 reaches 2.9:1 on the blue header, 0.75 reaches 4.5:1 (WCAG 1.4.11) */
#cis-main .modal-header .btn-close{
filter: invert(1);
--bs-btn-close-opacity: 0.75;
--bs-btn-close-hover-opacity: 1;
}
#cis-main .modal-footer {
+196
View File
@@ -0,0 +1,196 @@
/* Contentcomponents in CIS4 content.
*
* A contentcomponent renders inside its marker div, below #fhc-cms-content. The
* components themselves carry Bootstrap utility classes only, so without this file they
* read as unstyled Bootstrap.
*
* The class names are the contract with the components. A root class follows the pattern
* fhc-contentcomponent-<name> and is set in the template of the component.
*
* Colours come from the theme. public/css/theme/default.css declares the semantic
* variables under :root.light and :root.dark, never on bare :root, so every var() below
* carries a fallback. The fallback repeats the light value.
*
* Two themes are covered: light and dark. purple takes the light values.
*/
:root {
--fhc-cc-muted: #6c757d;
--fhc-cc-accent: var(--fhc-primary, #006095);
--fhc-cc-surface: var(--fhc-secondary, #f6f6f6);
--fhc-cc-hover: var(--fhc-tertiary, #e9ecef);
--fhc-cc-border: var(--fhc-border, #dee2e6);
--fhc-cc-radius: 0.3rem;
}
/* Two values need a step of their own. #006095 and #6c757d both fall under 3:1 on the
dark background, which is too little even for secondary text and for an icon. */
:root.dark {
--fhc-cc-muted: var(--fhc-white-90, #aeb3b8);
--fhc-cc-accent: var(--fhc-blue-40, #61aed8);
}
/* --------------------------------------------------------------------------------- */
/* The marker */
/* --------------------------------------------------------------------------------- */
/* The marker stays in the DOM and wraps the component. scan.js empties it, Teleport
renders the component root into it. It is therefore the place for the outer spacing. */
.fhc-contentcomponent {
display: block;
margin: 1.25rem 0;
}
.fhc-contentcomponent:first-child {
margin-top: 0;
}
.fhc-contentcomponent:last-child {
margin-bottom: 0;
}
.fhc-contentcomponent .alert {
margin-bottom: 0;
}
/* --------------------------------------------------------------------------------- */
/* Person block */
/* --------------------------------------------------------------------------------- */
/* PersonBlock.js. It carries person-block and every row of oe-personen. */
.fhc-contentcomponent-person {
/* flow-root contains the floated photo. It spares the clearfix element, which the
component does not render. */
display: flow-root;
padding: 0.75rem 1rem;
border: 1px solid var(--fhc-cc-border);
border-radius: var(--fhc-cc-radius);
background-color: var(--fhc-cc-surface);
/* The block carries mb-3. The gap of the two lists below owns the spacing instead,
so the last block does not push the content under it. Bootstrap writes the utility
with !important, so this needs it as well. */
margin-bottom: 0 !important;
}
/* The photo carries its width and its object-fit as an inline style. No rule here can
beat that, so only the placement is set. */
.fhc-contentcomponent-person > img {
float: left;
margin-right: 1rem;
border-radius: var(--fhc-cc-radius);
}
/* The name. It is the first div, with or without a photo above it. */
.fhc-contentcomponent-person > div:first-of-type {
font-size: 1.05em;
font-weight: 600;
line-height: 1.4;
}
/* Function, phone, room and mail. The mail and phone links keep the link colour. */
.fhc-contentcomponent-person > div:not(:first-of-type) {
font-size: 0.95em;
color: var(--fhc-cc-muted);
}
/* --------------------------------------------------------------------------------- */
/* oe-personen, person-block */
/* --------------------------------------------------------------------------------- */
.fhc-contentcomponent-oepersonen,
.fhc-contentcomponent-personkontakt {
display: grid;
gap: 0.75rem;
}
/* --------------------------------------------------------------------------------- */
/* dms-liste, dms-dokumente */
/* --------------------------------------------------------------------------------- */
/* Both render DokumentListe.js: ul.list-unstyled with one li per document. */
.fhc-contentcomponent-dmsliste ul,
.fhc-contentcomponent-dmsdokumente ul {
border: 1px solid var(--fhc-cc-border);
border-radius: var(--fhc-cc-radius);
/* Keeps the first and the last row inside the rounded corners. */
overflow: hidden;
}
.fhc-contentcomponent-dmsliste li,
.fhc-contentcomponent-dmsdokumente li {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--fhc-cc-border);
/* The row carries mb-2. The line above separates the rows instead. */
margin-bottom: 0 !important;
}
.fhc-contentcomponent-dmsliste li:first-child,
.fhc-contentcomponent-dmsdokumente li:first-child {
border-top: 0;
}
.fhc-contentcomponent-dmsliste li:hover,
.fhc-contentcomponent-dmsdokumente li:hover {
background-color: var(--fhc-cc-surface);
}
/* The file type icon sits inside the link and would take the link colour. A fixed width
lines the names up over every row, whatever the icon is. */
.fhc-contentcomponent-dmsliste li i,
.fhc-contentcomponent-dmsdokumente li i {
width: 1.25rem;
margin-right: 0.25rem;
text-align: center;
color: var(--fhc-cc-accent);
}
.fhc-contentcomponent-dmsliste li a,
.fhc-contentcomponent-dmsdokumente li a {
text-decoration: none;
}
.fhc-contentcomponent-dmsliste li a:hover,
.fhc-contentcomponent-dmsliste li a:focus,
.fhc-contentcomponent-dmsdokumente li a:hover,
.fhc-contentcomponent-dmsdokumente li a:focus {
text-decoration: underline;
}
/* The description is the only block element in the row. It starts below the icon, so it
takes the width of the icon as an indent and lines up with the document name. */
.fhc-contentcomponent-dmsliste li > div,
.fhc-contentcomponent-dmsdokumente li > div {
padding-left: 1.5rem;
}
/* --------------------------------------------------------------------------------- */
/* contentchild-menu */
/* --------------------------------------------------------------------------------- */
/* The successor of the legacy menubox. The accent rule replaces its grey background as
the mark that says: this is a menu, not running text. */
.fhc-contentcomponent-contentchildmenu ul {
padding: 0.5rem;
border-left: 3px solid var(--fhc-cc-accent);
border-radius: 0 var(--fhc-cc-radius) var(--fhc-cc-radius) 0;
background-color: var(--fhc-cc-surface);
}
.fhc-contentcomponent-contentchildmenu li {
/* The entry carries mb-1. The link padding owns the spacing instead. */
margin-bottom: 0 !important;
}
/* A block link makes the whole row the target, not the length of the title. */
.fhc-contentcomponent-contentchildmenu a {
display: block;
padding: 0.35rem 0.5rem;
border-radius: var(--fhc-cc-radius);
text-decoration: none;
}
.fhc-contentcomponent-contentchildmenu a:hover,
.fhc-contentcomponent-contentchildmenu a:focus {
background-color: var(--fhc-cc-hover);
text-decoration: underline;
}
+97
View File
@@ -0,0 +1,97 @@
/* Full-height shell. Without a height chain down to .cms-admin the page itself scrolls
and the tabs leave the screen while paging through the tree. */
html,
body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
overflow: hidden;
}
#main {
flex: 1 1 auto;
min-height: 0;
}
.cms-admin {
min-height: 0;
}
/* The split component only caps its height with max-height, so give it a definite one. */
.cms-admin .horizontalsplit-container {
height: 100%;
}
.cms-admin .horizontalsplitted {
min-width: 0;
overscroll-behavior: contain;
}
.cms-admin-main {
min-width: 0;
overflow: hidden;
}
.cms-admin-tab-content {
min-height: 0;
}
/* Anchor for the overlay that blocks the view during a content switch. */
.cms-admin {
position: relative;
}
.cms-admin-overlay {
position: absolute;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.6);
cursor: wait;
}
/* content locker to groups get a gold padlock - resembles legacy cms */
.cms-tree-lock {
color: #f0c000;
text-shadow: 0 0 1px #8a6000;
}
/* A frame swallows the mouse events that reach it, so dragging the split handle over the
preview ends the drag. Both frames go through for the length of the drag, so the events
reach the page underneath. Only a drag sets this class, so the preview stays clickable
the rest of the time. */
.cms-preview-dragging iframe {
pointer-events: none;
}
/* The path down to a search hit, and the subtree under it. Both are context, so they step
back and let the hit itself carry the normal weight. */
.cms-tree-ancestor {
font-size: 0.8em;
color: #6c757d;
line-height: 1.4;
}
/* The path is context and keeps the grey, but it leads somewhere, so it underlines on
hover like any other link. */
.cms-tree-ancestor a {
color: inherit;
text-decoration: none;
}
.cms-tree-ancestor a:hover,
.cms-tree-ancestor a:focus {
color: var(--bs-link-color, #0d6efd);
text-decoration: underline;
}
.cms-tree-context > div > a,
.cms-tree-context > div > span {
font-size: 0.85em;
color: #6c757d;
}
+22 -1
View File
@@ -121,8 +121,9 @@
background-color: #000;
}
/* contrast: #777 reaches 4.48:1 on the odd and 4:1 on the even rows (#f2f2f2), #6c6c6c 5.3:1 and 4.7:1 */
.tabulator-row.tabulator-unselectable:not(.tabulator-calcs) {
color: #777 !important;
color: #6c6c6c !important;
}
.tabulator-cell .btn {
@@ -156,3 +157,23 @@
color: #fff;
background: #1d68cd;
}
/* contrast (WCAG 1.4.11): tabulator_simple draws the inactive sort arrow in #bbb (1.9:1) and the
* placeholder in #ccc (1.6:1). #8f8f8f (3.2:1) is close to the grey of the active arrow, so the
* active arrow gets the primary color (6.8:1) */
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-sorter .tabulator-arrow,
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: #8f8f8f;
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--fhc-primary, #006095);
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-top-color: var(--fhc-primary, #006095);
}
.tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
color: #6c757d;
}
+81
View File
@@ -0,0 +1,81 @@
/* the panel of js/directives/inViewTooltip.js. place() sets its place, max width and max height
inside the viewport, so neither a scrolling container nor a modal body clips it */
.fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary, #0d6efd);
position: fixed;
z-index: 1080;
display: flex;
flex-direction: column;
width: max-content;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
background-color: var(--bs-body-bg, #fff);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
color: var(--bs-body-color, #212529);
font-size: 0.875rem;
font-weight: normal;
line-height: 1.5;
text-align: left;
}
/* the dark primary reaches 2:1 on the dark panel, the highlight 3.9:1 */
html.dark .fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary-highlight, #6ea8fe);
}
/* the body scrolls, so the progress border stays on the panel edge */
.fhc-in-view-tooltip-body {
min-height: 0;
overflow-y: auto;
padding: 0.75rem 1rem;
white-space: pre-line; /* the text keeps its line breaks */
}
/* the hover timer of a sticky panel: after a pause the border fills up clockwise, then the panel sticks.
The 2 px stroke covers the 1 px border and 1 px inside it */
.fhc-in-view-tooltip-progress {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.fhc-in-view-tooltip-progress rect {
fill: none;
stroke: var(--fhc-in-view-tooltip-progress);
stroke-width: 2;
stroke-dasharray: var(--fhc-in-view-tooltip-perimeter);
stroke-dashoffset: var(--fhc-in-view-tooltip-perimeter);
}
.fhc-in-view-tooltip--holding .fhc-in-view-tooltip-progress rect {
animation: fhc-in-view-tooltip-hold var(--fhc-in-view-tooltip-hold-fill) ease-in var(--fhc-in-view-tooltip-hold-delay) forwards;
}
/* a click during the hover timer fills the rest of the border quickly */
.fhc-in-view-tooltip--pinned .fhc-in-view-tooltip-progress rect {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.15s linear;
}
@keyframes fhc-in-view-tooltip-hold {
to {
stroke-dashoffset: 0;
}
}
/* the ? icon of components/InViewHelp.js takes the color of its context, e.g. white in the blue
modal header. A class like text-muted overrides it */
.fhc-in-view-help-btn,
.fhc-in-view-help-btn:hover,
.fhc-in-view-help-btn:focus-visible,
.btn.fhc-in-view-help-btn:active {
color: inherit;
}
.fhc-in-view-help-btn {
line-height: 1;
text-decoration: none;
}
+154 -3
View File
@@ -188,6 +188,9 @@
--fhc-light: var(--fhc-white-50);
--fhc-cis-menu-lvl-1-color: var(--fhc-white-50);
/* input borders, 3:1 against the background and the tinted card headers (#f8f8f9) */
--fhc-input-border: #868e96;
}
:root.dark {
@@ -233,6 +236,8 @@
--fhc-dark: var(--fhc-black-50);
--fhc-light: var(--fhc-white-70);
/* input borders, 3:1 against the background */
--fhc-input-border: #7c8791;
}
:root.purple {
@@ -313,12 +318,13 @@ html.dark .border-primary {
border-color: var(--fhc-primary) !important;
}
/* fhc danger*/
/* fhc danger: --fhc-danger is a light red for text and borders on the dark background. White text
* on it reaches 1.2:1, the backgrounds use the dark red (6.5:1) */
html.dark .bg-danger,
html.dark .btn-danger,
html.dark .btn-outline-danger {
background-color: var(--fhc-danger) !important;
border-color: var(--fhc-danger) !important;
background-color: var(--fhc-red-60) !important;
border-color: var(--fhc-red-60) !important;
color: white;
}
@@ -327,6 +333,25 @@ html.dark .btn-danger:hover {
filter: brightness(90%);
}
/* the dark primevue theme draws p-button-danger (e.g. FhcAlert.confirmDelete) light pink with black text,
* use the dark red of .btn-danger */
html.dark .p-button.p-button-danger {
color: white;
background: var(--fhc-red-60);
border-color: var(--fhc-red-60);
}
html.dark .p-button.p-button-danger:enabled:hover,
html.dark .p-button.p-button-danger:enabled:active {
color: white;
background: var(--fhc-red-70);
border-color: var(--fhc-red-70);
}
html.dark .p-button.p-button-danger:enabled:focus {
box-shadow: 0 0 0 0.2rem rgba(225, 110, 120, 0.5);
}
html.dark .text-danger {
color: var(--fhc-danger) !important;
}
@@ -335,6 +360,55 @@ html.dark .border-danger {
border-color: var(--fhc-danger) !important;
}
/* bootstrap draws btn-outline-secondary in #6c757d: 4.3:1 on the light dialog footer (#f6f6f6).
* --fhc-gray-30 reaches 5.4:1 there, the white hover text 5.9:1 */
html.light .btn-outline-secondary {
--bs-btn-color: var(--fhc-gray-30);
--bs-btn-border-color: var(--fhc-gray-30);
--bs-btn-hover-bg: var(--fhc-gray-30);
--bs-btn-hover-border-color: var(--fhc-gray-30);
--bs-btn-active-bg: var(--fhc-gray-30);
--bs-btn-active-border-color: var(--fhc-gray-30);
}
/* bootstrap keeps #6c757d for btn-outline-secondary in the dark theme: 2.4:1 on the dark tables and dialogs */
html.dark .btn-outline-secondary {
--bs-btn-color: var(--fhc-white-90);
--bs-btn-border-color: var(--fhc-white-90);
--bs-btn-hover-color: var(--fhc-black-30);
--bs-btn-hover-bg: var(--fhc-white-90);
--bs-btn-hover-border-color: var(--fhc-white-90);
--bs-btn-active-color: var(--fhc-black-30);
--bs-btn-active-bg: var(--fhc-white-90);
--bs-btn-active-border-color: var(--fhc-white-90);
}
/* the primevue multiselect renders the check icon also in unchecked boxes and hides it by color:
* white on white in the light theme, but #151515 on #20262e in the dark theme */
html.dark .p-checkbox .p-checkbox-box:not(.p-highlight) .p-checkbox-icon {
color: transparent;
}
/* FhcAlert.confirm passes bootstrap classes to the primevue buttons. The primevue theme loads after
* bootstrap, its .p-button colors replace the .btn colors: every button blue */
.p-button.btn {
color: var(--bs-btn-color);
background: var(--bs-btn-bg);
border-color: var(--bs-btn-border-color);
}
.p-button.btn:enabled:hover {
color: var(--bs-btn-hover-color);
background: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
}
.p-button.btn:enabled:active {
color: var(--bs-btn-active-color);
background: var(--bs-btn-active-bg);
border-color: var(--bs-btn-active-border-color);
}
html.dark .tabulator,
html.dark .tabulator .tabulator-header,
@@ -355,6 +429,52 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover {
background-color: var(--fhc-tertiary) !important;
}
/* the active sort arrow of tabulator_simple (#666) reaches 2:1 on the dark header */
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--fhc-text);
}
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-top-color: var(--fhc-text);
}
@media (hover: hover) and (pointer: fine) {
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-sorter.tabulator-col-sorter-element .tabulator-arrow:hover {
border-bottom-color: var(--fhc-text);
border-top-color: var(--fhc-text);
}
}
/* the group toggle arrow of tabulator_simple (#666) reaches 2:1 on the dark rows */
html.dark .tabulator .tabulator-row.tabulator-group:not(.tabulator-group-visible) .tabulator-arrow {
border-left-color: var(--fhc-text);
}
html.dark .tabulator .tabulator-row.tabulator-group.tabulator-group-visible .tabulator-arrow {
border-top-color: var(--fhc-text);
}
/* the footer stays white otherwise: the page buttons get the white button text of the dark color scheme */
html.dark .tabulator .tabulator-footer {
background-color: var(--fhc-tertiary);
border-top-color: var(--fhc-border);
color: var(--fhc-text);
}
html.dark .tabulator .tabulator-footer .tabulator-page {
background: transparent;
border-color: var(--fhc-white-90);
color: var(--fhc-text);
}
html.dark .tabulator .tabulator-footer .tabulator-page.active {
color: var(--fhc-red-30);
}
html.dark .tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
color: var(--fhc-white-90);
}
.p-paginator{
background-color: var(--fhc-background) !important;
}
@@ -376,4 +496,35 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover {
.p-link:focus {
box-shadow: 0 0 0 0.2rem rgba(var(--fhc-primary), 0.5) !important;
}
/* input borders: 3:1 against the background (WCAG 1.4.11), the bootstrap, primevue and datepicker defaults
* reach 1.3 - 1.9:1. Focus and checked states of the libraries stay */
.form-control,
.form-select,
.form-check-input,
.p-inputtext,
.p-inputtext:enabled:hover:not(:focus),
.p-dropdown,
.p-dropdown:not(.p-disabled):hover:not(.p-focus),
.p-multiselect,
.p-multiselect:not(.p-disabled):hover:not(.p-focus),
.p-checkbox .p-checkbox-box,
.p-radiobutton .p-radiobutton-box {
border-color: var(--fhc-input-border, #868e96);
}
/* the datepicker css loads after the theme, html raises the specificity */
html .dp__theme_light,
html .dp__theme_dark {
--dp-border-color: var(--fhc-input-border, #868e96);
--dp-border-color-hover: var(--fhc-border-highlight, #495057);
}
/* the light datepicker (also in the dark cis4 theme) draws the days of other months in #c0c4cc (1.8:1),
* the icons in #959595 (3:1) and the selected day in #f8f5f5 on #1976d2 (4.2:1) */
html .dp__theme_light {
--dp-secondary-color: #6c6c6c;
--dp-icon-color: #6c757d;
--dp-primary-text-color: #fff;
}
+51 -2
View File
@@ -16,7 +16,8 @@
*/
export default {
content(content_id, version=null, sprache=null, sichtbar=null) {
// preview flag to suppress click log entry. The CMS admin preview passes it.
content(content_id, version=null, sprache=null, sichtbar=null, preview=false) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/content',
@@ -24,7 +25,8 @@ export default {
content_id,
...(version ? { version } : {}),
...(sprache ? { sprache } : {}),
...(sichtbar ? { sichtbar } : {})
...(sichtbar ? { sichtbar } : {}),
...(preview ? { preview: 1 } : {})
}
};
},
@@ -54,6 +56,53 @@ export default {
url: '/api/frontend/v1/Cms/getNewsRowCount'
};
},
// Used by the CMS content component "oe-personen".
// tiefe 0 asks about the one unit, higher takes that many levels of sub units with it.
getOePersonen(oe_kurzbz, foto, tiefe) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getOePersonen',
params: {
oe_kurzbz,
...(foto ? { foto: 1 } : {}),
...(tiefe ? { tiefe } : {})
}
};
},
// Used by the CMS content component "person-block".
getPerson(uid, foto) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getPerson',
params: { uid, ...(foto ? { foto: 1 } : {}) }
};
},
// Used by the CMS content component "dms-dokumente".
getDmsDokumente(dms_ids) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getDmsDokumente',
params: { dms_ids }
};
},
// Used by the CMS content component "dms-liste".
// tiefe 0 asks about the one category, higher takes that many levels of sub categories
// with it. The server checks the entitlement of every category it reaches.
getDmsKategorie(kategorie_kurzbz, tiefe) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getDmsKategorie',
params: { kategorie_kurzbz, ...(tiefe ? { tiefe } : {}) }
};
},
// Used by the CMS content component "contentchild-menu".
getContentChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getContentChilds',
params: { content_id, ...(sprache ? { sprache } : {}) }
};
},
getNewsExtra() {
// TODO(chris): seems to be called from nowhere?
return {
+296
View File
@@ -0,0 +1,296 @@
export default {
// --- CmsAdmin ---
getTree(menu, filter) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getTree',
params: { menu, ...(filter ? { filter } : {}) }
};
},
getSubtree(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getSubtree',
params: { content_id }
};
},
getContent(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getContent',
params: { content_id }
};
},
getContentsprache(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getContentsprache',
params: { content_id, sprache, version }
};
},
// Feeds the tree filter: child count, insertamum, updateamum and the own-edit flag.
getTreeMeta() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getTreeMeta'
};
},
// Where each content sits. The tree asks for the whole visible list at once, so the
// hierarchy toggle costs one request and not one per node.
getAncestors(content_ids) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getAncestors',
params: { content_ids: content_ids.join(',') }
};
},
// Views of one content. One counting query, so the tab loads it right away.
// The server gates both click endpoints on the config flag and on the admin right,
// so a caller without both gets an error instead of numbers.
getClickCount(content_id, months) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getClickCount',
params: { content_id, months }
};
},
// The ranking over the whole log. Takes seconds, so the tab asks for it on request.
getClickRanking(months, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getClickRanking',
params: { months, sprache }
};
},
getTemplates() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getTemplates'
};
},
getOrganisationseinheiten() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getOrganisationseinheiten'
};
},
getSprachen() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getSprachen'
};
},
// Fills the DMS category field of the contentcomponent insert dialog.
getDmsKategorien() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getDmsKategorien'
};
},
// Fills the person field of the contentcomponent insert dialog.
getMitarbeiter() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getMitarbeiter'
};
},
// Fills the document field of the contentcomponent insert dialog.
getDmsKategorieDokumente(kategorie_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getDmsKategorieDokumente',
params: { kategorie_kurzbz }
};
},
getUsage(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getUsage',
params: { content_id }
};
},
postContent(parent_content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postContent',
params: { parent_content_id }
};
},
postTranslation(content_id, sprache, version, target_sprache) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postTranslation',
params: { content_id, sprache, version, target_sprache }
};
},
postVersion(content_id, sprache) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postVersion',
params: { content_id, sprache }
};
},
putProperties(content_id, sprache, version, template_kurzbz, oe_kurzbz, aktiv, menu_open, beschreibung, titel, sichtbar) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/putProperties',
params: { content_id, sprache, version, template_kurzbz, oe_kurzbz, aktiv, menu_open, beschreibung, titel, sichtbar }
};
},
deleteContent(content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/deleteContent',
params: { content_id }
};
},
deleteContentsprache(content_id, sprache, version) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/deleteContentsprache',
params: { content_id, sprache, version }
};
},
// --- CmsAdminInhalt ---
getFormSchema(template_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getFormSchema',
params: { template_kurzbz }
};
},
getFormData(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getFormData',
params: { content_id, sprache, version }
};
},
getLock(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getLock',
params: { content_id, sprache, version }
};
},
getVersions(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getVersions',
params: { content_id, sprache }
};
},
putFormData(content_id, sprache, version, values) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/putFormData',
params: { content_id, sprache, version, values }
};
},
postLock(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/postLock',
params: { contentsprache_id }
};
},
deleteLock(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/deleteLock',
params: { contentsprache_id }
};
},
deleteLockForced(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/deleteLockForced',
params: { contentsprache_id }
};
},
// --- CmsAdminStruktur ---
getGruppen(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getGruppen',
params: { content_id }
};
},
getAllGruppen() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getAllGruppen'
};
},
getChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getChilds',
params: { content_id, sprache }
};
},
// The hierarchy tab lists them above the children. A content can have several parents.
getParents(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getParents',
params: { content_id, sprache }
};
},
getPossibleChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getPossibleChilds',
params: { content_id, sprache }
};
},
postGruppe(content_id, gruppe_kurzbz) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/postGruppe',
params: { content_id, gruppe_kurzbz }
};
},
deleteGruppe(content_id, gruppe_kurzbz) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/deleteGruppe',
params: { content_id, gruppe_kurzbz }
};
},
postChild(content_id, child_content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/postChild',
params: { content_id, child_content_id }
};
},
deleteChild(contentchild_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/deleteChild',
params: { contentchild_id }
};
},
putChildSort(contentchild_id, direction) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/putChildSort',
params: { contentchild_id, direction }
};
},
// Writes the whole order after a drag and drop. The ids carry the wanted order.
putChildOrder(content_id, contentchild_ids) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/putChildOrder',
params: { content_id, contentchild_ids }
};
}
};
+6 -1
View File
@@ -150,7 +150,8 @@ const router = VueRouter.createRouter({
}
},
{
path: `/CisVue/Cms/Content/:content_id`,
// optional version for cms admin preview
path: `/CisVue/Cms/Content/:content_id/:version?`,
name: 'Content',
component: CmsContent,
props: true
@@ -350,5 +351,9 @@ app.directive('contrast', contrast);
app.mount('#fhccontent');
router.afterEach((to, from, failure) => {
// dont log cms admin preview
if (to.query.preview === '1')
return;
app.config.globalProperties.$api.call(ApiRouteInfo.info('cis4', to.fullPath));
});
+44
View File
@@ -0,0 +1,44 @@
import CmsAdmin from "../components/Cms/CmsAdmin.js";
import PluginsPhrasen from "../plugins/Phrasen.js";
const ciPath = FHC_JS_DATA_STORAGE_OBJECT.app_root.replace(/(https:|)(^|\/\/)(.*?\/)/g, '')
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router;
const router = VueRouter.createRouter({
history: VueRouter.createWebHistory('/' + ciPath),
routes: [
{
name: 'index',
path: '/cms',
component: CmsAdmin,
children: [
{
// Only content_id is required. CmsAdmin fills the missing parts
// from the content itself and rewrites the address.
name: 'content',
path: 'content/:content_id/:sprache?/:version?/:tab?',
component: CmsAdmin
}
]
},
{ path: '/:pathMatch(.*)*', redirect: { name: 'index' } }
]
});
FhcApps.router.makeExtendable(router);
const app = Vue.createApp({
name: 'CmsApp'
});
FhcApps.makeExtendable(app);
app
.use(router)
.use(primevue.config.default, {
zIndex: {
overlay: 1100
}
})
.use(PluginsPhrasen)
.mount('#main');
+2 -2
View File
@@ -73,6 +73,7 @@ export default {
}
},
computed: {
// opacity 0.5 reaches 2.4:1 on the blue cis4 modal header, 0.75 reaches 3.6:1 (WCAG 1.4.11)
getExpandButtonStyles() {
const hovered = this.expandBtnHovered;
const focused = this.expandBtnFocused;
@@ -85,12 +86,11 @@ export default {
border: 0;
background: transparent;
font-size: 1em;
opacity: 0.5;
color: inherit;
cursor: pointer;
line-height: 1;
transition: opacity 0.15s ease;
opacity: ${focused ? '1' : hovered ? '0.75' : '0.5'};
opacity: ${focused || hovered ? '1' : '0.75'};
outline: ${focused ? '1px solid currentColor' : 'none'};
outline-offset: 2px;`
}
@@ -54,7 +54,6 @@ export const AbgabetoolAssistenz = {
qgate2FilterSelected: [],
pa_noteFilterSelected: [],
noteFilterSelected: [],
note_bezFilterSelected: [],
count: 0,
filteredcount: 0,
selectedcount: 0,
@@ -212,7 +211,7 @@ export const AbgabetoolAssistenz = {
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4orgformv2'))), field: 'orgform', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 50, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4projekttyp'))), field: 'projekttyp_kurzbz', formatter: this.centeredTextFormatter, minWidth: 150, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4stg'))), field: 'stg', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 50, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4note'))), field: 'note_bez', headerFilter: this.notenHeaderFilterEditor, headerFilterFunc: this.notenHeaderFilterFunc, headerFilterParams: {}, headerFilterFuncParams: { noteField: 'note' }, sorter: this.notenSorter, visible: false, minWidth: 200, formatter: this.centeredTextFormatter},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4note'))), field: 'note_bez', headerFilter: true, sorter: this.notenSorter, visible: false, minWidth: 200, formatter: this.centeredTextFormatter},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4sem'))), field: 'studiensemester_kurzbz', headerFilter: true, visible: false, formatter: this.centeredTextFormatter, minWidth: 100},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4titel'))), field: 'titel', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 100, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4erstbetreuerv2'))), field: 'erstbetreuer', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 100, visible: false},
@@ -537,6 +536,8 @@ export const AbgabetoolAssistenz = {
return aData.note - bData.note
},
notenHeaderFilterEditor(cell, onRendered, success, cancel, editorParams) {
if (!this.notenOptions) return;
const field = cell.getField();
const stateKey = field + 'FilterSelected';
let selected = [...(this[stateKey] || [])];
@@ -553,16 +554,21 @@ export const AbgabetoolAssistenz = {
dropdown.style.cssText = 'display: none; position: fixed; background: #fff; border: 1px solid; z-index: 9999; min-width: 180px; box-shadow: 0 2px 6px rgba(0,0,0,0.15);';
// the table is built before getNoten resolves, so resolve the options on open
const getOptions = () => (field === 'note' ? this.allowedNotenOptions : this.notenOptions) ?? [];
// mapping evaluated at render time, not at column definition time
const fieldOptionsMap = {
'pa_note': this.notenOptions,
'note': this.allowedNotenOptions,
};
const options = fieldOptionsMap[cell.getField()] ?? this.notenOptions;
if (!options) return;
const updateDisplay = () => {
display.value = getOptions()
display.value = options
.filter(o => selected.includes(o.note))
.map(o => o.bezeichnung)
.join(', ');
};
const buildDropdown = () => dropdown.replaceChildren(...getOptions().map(opt => {
options.forEach(opt => {
const row = document.createElement('label');
row.style.cssText = 'display: flex; align-items: center; gap: 6px; padding: 4px 8px; cursor: pointer; white-space: nowrap;';
row.addEventListener('mousedown', e => e.preventDefault());
@@ -586,15 +592,13 @@ export const AbgabetoolAssistenz = {
row.appendChild(cb);
row.appendChild(labelText);
return row;
}));
dropdown.appendChild(row);
});
updateDisplay();
display.addEventListener('click', () => {
if (dropdown.style.display === 'none') {
buildDropdown();
updateDisplay();
const rect = display.getBoundingClientRect();
dropdown.style.top = rect.bottom + 'px';
dropdown.style.left = rect.left + 'px';
@@ -617,11 +621,9 @@ export const AbgabetoolAssistenz = {
},
notenHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
if (!Array.isArray(filterVal) || !filterVal.length) return true;
// noteField: the column shows a text, the note id is in another field
const noteVal = filterParams?.noteField ? rowData[filterParams.noteField] : rowVal;
// noteVal is the raw integer note id or a note object
const noteId = typeof noteVal === 'object' ? noteVal?.note : noteVal;
if (!filterVal || !filterVal.length) return true;
// rowVal is the raw integer note id or a note object
const noteId = typeof rowVal === 'object' ? rowVal?.note : rowVal;
return filterVal.some(val => val == noteId); // loose equality: filter vals are numbers, noteId might be string
},
handleFilterActiveChanged(active) {
@@ -1494,8 +1496,6 @@ export const AbgabetoolAssistenz = {
for (let hf of saved.headerFilters) {
if (hf.field === 'qgate1Status') this.qgate1FilterSelected = hf.value || [];
if (hf.field === 'qgate2Status') this.qgate2FilterSelected = hf.value || [];
// older saved states hold a text filter here
if (hf.field === 'note_bez') this.note_bezFilterSelected = Array.isArray(hf.value) ? hf.value : [];
table.setHeaderFilterValue(hf.field, hf.value);
}
}
@@ -40,7 +40,6 @@ export const AbgabetoolMitarbeiter = {
selectedcount: 0,
qgate1FilterSelected: [],
qgate2FilterSelected: [],
note_bezFilterSelected: [],
abgabetypenBetreuer: null,
detailIsFullscreen: false,
phrasenPromise: null,
@@ -146,7 +145,7 @@ export const AbgabetoolMitarbeiter = {
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4orgformv2'))), field: 'orgform', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 50, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4projekttyp'))), field: 'projekttyp_kurzbz', formatter: this.centeredTextFormatter, minWidth: 100,visible: true},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4stg'))), field: 'stg', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 50, visible: true},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4note'))), field: 'note_bez', headerFilter: this.notenHeaderFilterEditor, headerFilterFunc: this.notenHeaderFilterFunc, headerFilterParams: {}, headerFilterFuncParams: { noteField: 'note' }, visible: false, sorter: this.notenSorter, minWidth: 200, formatter: this.centeredTextFormatter},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4note'))), field: 'note_bez', headerFilter: true, visible: false, sorter: this.notenSorter, minWidth: 200, formatter: this.centeredTextFormatter},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4sem'))), field: 'studiensemester_kurzbz', headerFilter: true, formatter: this.centeredTextFormatter, visible: true, minWidth: 100},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4titel'))), field: 'titel', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 100, width: 500, visible: true},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4erstbetreuerv2'))), field: 'betreuer_full_name', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 100, visible: false},
@@ -271,94 +270,6 @@ export const AbgabetoolMitarbeiter = {
const bData = bRow.getData()
return aData.note - bData.note
},
notenHeaderFilterEditor(cell, onRendered, success, cancel, editorParams) {
const field = cell.getField();
const stateKey = field + 'FilterSelected';
let selected = [...(this[stateKey] || [])];
const wrapper = document.createElement('div');
wrapper.style.cssText = 'position: relative; width: 100%;';
const display = document.createElement('input');
display.readOnly = true;
display.placeholder = '';
display.style.cssText = 'padding: 4px; width: 100%; box-sizing: border-box; cursor: default; border: 1px solid; outline: none; background: #fff; appearance: none; caret-color: transparent;';
const dropdown = document.createElement('div');
dropdown.style.cssText = 'display: none; position: fixed; background: #fff; border: 1px solid; z-index: 9999; min-width: 180px; box-shadow: 0 2px 6px rgba(0,0,0,0.15);';
// the table is built before getNoten resolves, so resolve the options on open
const getOptions = () => this.notenOptions ?? [];
const updateDisplay = () => {
display.value = getOptions()
.filter(o => selected.includes(o.note))
.map(o => o.bezeichnung)
.join(', ');
};
const buildDropdown = () => dropdown.replaceChildren(...getOptions().map(opt => {
const row = document.createElement('label');
row.style.cssText = 'display: flex; align-items: center; gap: 6px; padding: 4px 8px; cursor: pointer; white-space: nowrap;';
row.addEventListener('mousedown', e => e.preventDefault());
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = opt.note;
cb.checked = selected.includes(opt.note);
cb.style.cssText = 'margin: 0 6px;';
cb.addEventListener('change', () => {
selected = cb.checked
? [...selected, opt.note]
: selected.filter(v => v !== opt.note);
this[stateKey] = [...selected];
updateDisplay();
success([...selected]);
});
const labelText = document.createElement('span');
labelText.textContent = opt.bezeichnung;
row.appendChild(cb);
row.appendChild(labelText);
return row;
}));
updateDisplay();
display.addEventListener('click', () => {
if (dropdown.style.display === 'none') {
buildDropdown();
updateDisplay();
const rect = display.getBoundingClientRect();
dropdown.style.top = rect.bottom + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.display = 'block';
} else {
dropdown.style.display = 'none';
}
});
display.addEventListener('blur', () => {
setTimeout(() => { dropdown.style.display = 'none'; }, 150);
});
document.body.appendChild(dropdown);
wrapper.appendChild(display);
cell.getElement().addEventListener('remove', () => dropdown.remove());
onRendered(() => display.focus());
return wrapper;
},
notenHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
if (!Array.isArray(filterVal) || !filterVal.length) return true;
// noteField: the column shows a text, the note id is in another field
const noteVal = filterParams?.noteField ? rowData[filterParams.noteField] : rowVal;
// noteVal is the raw integer note id or a note object
const noteId = typeof noteVal === 'object' ? noteVal?.note : noteVal;
return filterVal.some(val => val == noteId); // loose equality: filter vals are numbers, noteId might be string
},
async openBenotung(type, link) {
if(type === 'new') {
window.open(link, '_blank')
@@ -871,8 +782,6 @@ export const AbgabetoolMitarbeiter = {
if (saved?.headerFilters && !this.headerFiltersRestored) {
this.headerFiltersRestored = true // instantly avoid retriggers
for (let hf of saved.headerFilters) {
// older saved states hold a text filter here
if (hf.field === 'note_bez') this.note_bezFilterSelected = Array.isArray(hf.value) ? hf.value : [];
table.setHeaderFilterValue(hf.field, hf.value);
}
}
+10 -2
View File
@@ -38,7 +38,9 @@ export default {
methods: {
fetchContent(){
this.$api
.call(ApiCms.content(this.content_id_internal, this.version, this.sprache, this.sichtbar))
.call(ApiCms.content(
this.content_id_internal, this.version, this.sprache, this.sichtbar, this.preview
))
.then(res => {
this.$nextTick(function() {
this.content = res.data.content;
@@ -57,8 +59,14 @@ export default {
}
},
computed: {
// The viewer language, unless the address names one. The CMS admin previews a
// chosen language, which is not always the language of the editor interface.
sprache(){
return this.$p.user_language.value;
return this.$route?.query?.sprache || this.$p.user_language.value;
},
// A preview renders the content without counting a view.
preview(){
return this.$route?.query?.preview === '1';
},
computeContentType: function () {
switch (this.content_type) {
@@ -1,4 +1,5 @@
import { replaceRelativeLegacyLink } from "../../../../helpers/LegacyLinkReplaceHelper.js"
import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js";
export default {
name: "GeneralComponent",
props:{
@@ -6,7 +7,13 @@ export default {
type:String,
required:true,
},
content_id:{
type: [Number, String],
},
},
components: {
ContentcomponentHost
},
methods: {
sanitizeLegacyTables(table) {
@@ -124,7 +131,7 @@ export default {
template: /*html*/ `
<!-- div that contains the content -->
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
<div v-html="content" ></div>
<contentcomponent-host :content="content" :content-id="content_id"></contentcomponent-host>
</div></div></div>
<p v-else>Content was not found</p>
`,
@@ -134,6 +134,7 @@ export default {
template: /*html*/ `
<!-- div that contains the content -->
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
<!-- No contentcomponent-host on purpose: news contents stay static by convention. -->
<div v-html="content" ></div>
</div></div></div>
<p v-else>Content was not found</p>
@@ -1,4 +1,4 @@
import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js";
export default {
name: "RaumComponent",
data() {
@@ -15,6 +15,9 @@ export default {
type: [Number, String],
}
},
components: {
ContentcomponentHost
},
methods: {
sanitizeLegacyTables(table) {
@@ -135,7 +138,7 @@ export default {
<!-- div that contains the content -->
<!-- TODO: test with more img content from cms-->
<div v-if="imgContent"><img v-bind="imgContent"/></div>
<div v-html="content" v-else-if="content" ></div>
<contentcomponent-host v-else-if="content" :content="content" :content-id="content_id"></contentcomponent-host>
<p v-else>Content was not found</p>
`,
};
+1
View File
@@ -117,6 +117,7 @@ export default {
></pagination>
<div class="container-fluid mt-4">
<div class="row">
<!-- No contentcomponent-host on purpose: news stays static by convention. -->
<div class="col" v-html="content">
</div>
<div class="col-auto">
@@ -2,15 +2,16 @@ export default {
props: {
data: {
type: String,
}
},
},
data() {
return {}
return {};
},
template: /*html*/`
template: /*html*/ `
<div class="card">
<div class="card-body">
<span >{{$p.t('profil','fhAusweisStatus',[data])}}</span>
<span v-if="data">{{$p.t('profil','fhAusweisStatus',[data])}}</span>
<span v-else>{{$p.t('profil','fhAusweisStatusNichtAusgegeben')}}</span>
</div>
</div>`,
}
};
@@ -66,6 +66,24 @@ export default {
return "d-none d-xl-grid";
}
},
// link into the FHC-Core-Anwesenheiten tool
// digi_anw_data is attached per event by the extension's `extendStundenplanData` listener
anwLink() {
const d = this.event && this.event.digi_anw_data;
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
return null;
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/extensions/FHC-Core-Anwesenheiten/';
return base
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
+ `&lvid=${encodeURIComponent(d.lv_id)}`
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
},
anwLinkTitle() {
return this.$p.t('global/anwKontrolleOeffnen');
},
},
template: /*html*/`
<div
@@ -96,6 +114,21 @@ export default {
... +{{ event.lektor.length - 3 }}
</span>
</div>
<a
v-if="anwLink"
:href="anwLink"
target="_blank"
rel="noopener"
class="d-flex align-items-center flex-shrink-0"
style="margin-right: 4px"
:title="anwLinkTitle"
:aria-label="anwLinkTitle"
draggable="false"
@click.stop
@mousedown.stop
>
<i class="fa fa-arrow-up-right-from-square fs-5" aria-hidden="true"></i>
</a>
</div>
`,
}
@@ -49,6 +49,20 @@ export default {
return this.event.ende;
}
return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes());
},
// Deep link into the attendance tool; only present when the anw extension wrote into digi_anw_data
anwLink: function () {
const d = this.event && this.event.digi_anw_data;
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
return null;
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/extensions/FHC-Core-Anwesenheiten/';
return base
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
+ `&lvid=${encodeURIComponent(d.lv_id)}`
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
}
},
watch: {
@@ -160,6 +174,16 @@ export default {
}}</th>
<td>{{event.organisationseinheit}}</td>
</tr>
<tr v-if="anwLink">
<th>{{
$p.t('global','anwKontrolle')?
$p.t('global','anwKontrolle')+':'
:''
}}</th>
<td>
<a :href="anwLink" target="_blank" rel="noopener" :aria-label="$p.t('global','anwKontrolleOeffnen')" :title="$p.t('global','anwKontrolleOeffnen')"><i class="fa fa-arrow-up-right-from-square me-1" style="color:#00649C" aria-hidden="true"></i></a>
</td>
</tr>
</tbody>
</table>
<lv-menu v-if="lvMenu.length && $route.name === 'MyLvPlan'" :containerStyles="['p-0']" :rowStyles="['m-0']" :menu="lvMenu" />
+303
View File
@@ -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: `
<div class="cms-admin h-100">
<horizontal-split :default-ratio="[25, 75]">
<template #left>
<cms-tree
ref="tree"
:active-content-id="contentId"
@select-content="selectContent"
></cms-tree>
</template>
<template #right>
<div class="cms-admin-main h-100 d-flex flex-column" v-if="contentId !== null">
<cms-content-header
:content-id="contentId"
:sprache="sprache"
:version="version"
:contentInfo="contentInfo"
@select-language-version="selectLanguageVersion"
@reload-content-info="loadContentInfo"
></cms-content-header>
<ul class="nav nav-tabs px-3">
<li class="nav-item" v-for="t in tabs" :key="t.key">
<a class="nav-link"
:class="{ active: tab === t.key }"
href="#"
@click.prevent="selectTab(t.key)"
>{{ $p.t(t.phrase) }}</a>
</li>
</ul>
<div class="cms-admin-tab-content flex-grow-1 overflow-auto">
<component
:is="activeTabComponent"
:content-id="contentId"
:sprache="sprache"
:version="version"
:contentInfo="contentInfo"
@reload-content-info="loadContentInfo"
@reload-tree="reloadTree"
@refresh-tree-node="refreshTreeNode"
@patch-tree-node="patchTreeNode"
@select-content="selectContent"
></component>
</div>
</div>
<div class="cms-admin-main h-100 d-flex align-items-center justify-content-center text-muted" v-else>
{{ $p.t('cms/keinEintragGewaehlt') }}
</div>
</template>
</horizontal-split>
<div v-if="busy" class="cms-admin-overlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ $p.t('ui/loading') }}</span>
</div>
</div>
</div>
`
};
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 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 '<div class="' + CONTENTCOMPONENT_CLASS + '" '
+ CONTENTCOMPONENT_ATTR + '="' + name + '"' + attrs + '></div>';
}
// 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
};
}
@@ -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: `
<div class="mb-3">
<label class="form-label">
{{ label }}<span v-if="required" class="text-danger"> *</span>
</label>
<input
class="form-control"
:type="type"
:value="modelValue"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
`
};
@@ -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: `
<div class="mb-3">
<label class="form-label">
{{ label }}<span v-if="required" class="text-danger"> *</span>
</label>
<textarea ref="editorArea" :id="editorId"></textarea>
</div>
`
};
+72
View File
@@ -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: `
<div v-if="schema && schema.fields">
<template v-for="field in schema.fields" :key="field.name">
<field-wysiwyg
v-if="field.type === 'wysiwyg'"
:ref="'wysiwyg-' + field.name"
:modelValue="modelValue[field.name] || ''"
:label="field.name"
:required="field.required"
:disabled="disabled"
@update:modelValue="updateField(field.name, $event)"
></field-wysiwyg>
<div v-else-if="field.type === 'boolean'" class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
:checked="!!modelValue[field.name]"
:disabled="disabled"
@change="updateField(field.name, $event.target.checked)"
:id="'field-' + field.name"
>
<label class="form-check-label" :for="'field-' + field.name">
{{ field.name }}<span v-if="field.required" class="text-danger"> *</span>
</label>
</div>
</div>
<field-string
v-else
:modelValue="modelValue[field.name] || ''"
:label="field.name"
:required="field.required"
:disabled="disabled"
:type="field.type === 'date' ? 'date' : 'text'"
@update:modelValue="updateField(field.name, $event)"
></field-string>
</template>
</div>
`
};
@@ -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: `
<div class="px-3 pt-2 pb-2 border-bottom" v-if="contentInfo">
<div class="mb-2 fw-bold">
{{ $p.t('cms/contentId') }}: {{ contentId }}
| {{ $p.t('cms/version') }}: {{ version }}
| {{ $p.t('cms/sprache') }}: {{ sprache }}
| {{ $p.t('cms/titel') }}: {{ titel }}
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<version-select
:versions="versions"
:version-details="versionDetails"
:version="version"
@select-version="selectVersion"
></version-select>
<sprache-select
:languages="languages"
:sprache="sprache"
@select-language="selectLanguage"
></sprache-select>
</div>
<div>
<button class="btn btn-sm btn-outline-primary"
@click="createVersion"
>{{ $p.t('cms/neueVersionAnlegen') }}</button>
</div>
<div class="d-flex align-items-center gap-1">
<!-- One button per missing language is fine for a pair. An institution
with more content languages would get a wall of them, each
carrying the full label. -->
<template v-if="availableSprachen.length > 2">
<select class="form-select form-select-sm w-auto"
v-model="neueSprache">
<option value="">{{ $p.t('cms/spracheWaehlen') }}</option>
<option v-for="s in availableSprachen" :key="s.sprache"
:value="s.sprache">{{ s.sprache }}</option>
</select>
<button class="btn btn-sm btn-outline-primary"
:disabled="!neueSprache"
@click="createTranslation(neueSprache)"
>{{ $p.t('cms/uebersetzungAnlegen') }}</button>
</template>
<template v-else>
<button v-for="s in availableSprachen" :key="s.sprache"
class="btn btn-sm btn-outline-primary"
@click="createTranslation(s.sprache)"
>{{ $p.t('cms/uebersetzungAnlegenIn', [s.sprache]) }}</button>
</template>
</div>
</div>
</div>
`
};
@@ -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: `
<select v-if="alsAuswahl"
class="form-select form-select-sm w-auto cms-sprache-select"
:title="$p.t('cms/sprache')"
:value="sprache"
@change="$emit('select-language', $event.target.value)"
>
<option v-for="lang in languages" :key="lang" :value="lang">{{ lang }}</option>
</select>
<div v-else class="btn-group btn-group-sm cms-sprache-select" role="group">
<button
v-for="lang in languages"
:key="lang"
type="button"
class="btn"
:class="lang === sprache ? 'btn-primary' : 'btn-outline-secondary'"
@click="$emit('select-language', lang)"
>{{ lang }}</button>
</div>
`
};
@@ -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: `
<div class="btn-group btn-group-sm" role="group">
<template v-for="(item, idx) in items" :key="item.type + idx">
<button
v-if="item.type === 'version'"
type="button"
class="btn"
:class="[
item.value === version ? 'btn-primary' : 'btn-outline-secondary',
{ 'opacity-50': !isSichtbar(item.value) }
]"
@click="$emit('select-version', item.value)"
>{{ item.value }}</button>
<button
v-else
type="button"
class="btn btn-outline-secondary"
disabled
:title="$p.t('cms/weitereVersionen', [item.count])"
>&hellip;</button>
</template>
</div>
`
};
+308
View File
@@ -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: `
<div class="mt-3" :class="{ 'cms-preview-dragging': dragging }">
<div v-if="sichtbar === false" class="text-danger fw-bold mb-2">
{{ $p.t('cms/unsichtbarImLivesystem') }}
</div>
<div class="mb-2">
<a :href="cis4Url" target="_blank">{{ $p.t('cms/inCis4Ansehen') }}</a>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn"
:class="mode === 'legacy' ? 'btn-primary' : 'btn-outline-secondary'"
@click="selectMode('legacy')"
>{{ $p.t('cms/vorschauLegacy') }}</button>
<button type="button" class="btn"
:class="mode === 'cis4' ? 'btn-primary' : 'btn-outline-secondary'"
@click="selectMode('cis4')"
>{{ $p.t('cms/vorschauCis4') }}</button>
</div>
<div v-if="mode === 'cis4' && !isRedirect"
class="btn-group btn-group-sm" role="group">
<button type="button" class="btn"
:class="cis4Width === 'desktop' ? 'btn-primary' : 'btn-outline-secondary'"
:title="$p.t('cms/breiteDesktop')"
@click="selectWidth('desktop')"
><i class="fa-solid fa-display"></i></button>
<button type="button" class="btn"
:class="cis4Width === 'mobile' ? 'btn-primary' : 'btn-outline-secondary'"
:title="$p.t('cms/breiteMobil')"
@click="selectWidth('mobile')"
><i class="fa-solid fa-mobile-screen-button"></i></button>
</div>
</div>
<template v-if="mode === 'legacy'">
<div v-if="hasContentcomponents" class="alert alert-info py-2">
{{ $p.t('cms/contentcomponentsNurCis4') }}
</div>
<template v-if="isRedirect">
<a :href="previewUrl" target="_blank">
{{ $p.t('cms/vorschauInEigenemFenster') }}
</a>
</template>
<template v-else>
<iframe
ref="previewFrame"
:src="previewUrl"
style="width: 100%; min-height: 500px; border: 1px solid #ccc;"
></iframe>
</template>
</template>
<template v-else>
<div v-if="!hasCis4Stylesheet" class="alert alert-warning py-2">
{{ $p.t('cms/keinCis4Stylesheet') }}
</div>
<template v-if="isRedirect">
<a :href="cis4PreviewUrl" target="_blank">
{{ $p.t('cms/vorschauInEigenemFenster') }}
</a>
</template>
<template v-else>
<div class="position-relative border" :style="cis4WrapStyle">
<iframe
ref="cis4Frame"
:src="cis4PreviewUrl"
@load="onCis4Load"
style="width: 100%; min-height: 500px; border: 0;
display: block;"
></iframe>
<div v-if="cis4Loading"
class="position-absolute top-0 start-0 w-100 h-100 d-flex
flex-column align-items-center justify-content-center"
style="background: rgba(255, 255, 255, 0.85);">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ $p.t('ui/loading') }}</span>
</div>
<div class="mt-2 text-muted small">
{{ $p.t('cms/vorschauLaedt') }}
</div>
</div>
</div>
</template>
</template>
</div>
`
};
+153
View File
@@ -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: `
<div class="p-3" v-if="contentId != null">
<div class="d-flex gap-2 align-items-center mb-3">
<label class="form-label mb-0">{{ $p.t('cms/zeitraum') }}</label>
<select class="form-select form-select-sm"
style="max-width: 220px"
v-model.number="months"
:disabled="loadingOwn || loadingRanking"
@change="onPeriodChange">
<option v-for="m in monthOptions" :key="m" :value="m">
{{ m ? $p.t('cms/letzteNMonate', [m]) : $p.t('cms/gesamterZeitraum') }}
</option>
</select>
<i v-if="loadingOwn" class="fa-solid fa-spinner fa-spin"></i>
</div>
<div class="form-text mb-3">
{{ since
? $p.t('cms/klicksSeit', [formatDate(since)])
: $p.t('cms/klicksGesamt') }}
</div>
<div class="mb-4">
<h5>{{ $p.t('cms/aufrufeDiesesEintrags') }}</h5>
<span class="fs-4">{{ own === null ? '-' : own }}</span>
</div>
<h5>{{ $p.t('cms/meistAufgerufeneEintraege') }}</h5>
<button v-if="!rankingLoaded"
class="btn btn-outline-primary"
:disabled="loadingRanking"
@click="loadRanking">
<i v-if="loadingRanking" class="fa-solid fa-spinner fa-spin me-1"></i>
{{ $p.t('cms/alleKlickstatistikenLaden') }}
</button>
<table v-else-if="ranked.length > 0" class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $p.t('cms/rang') }}</th>
<th>ID</th>
<th>{{ $p.t('cms/titel') }}</th>
<th>{{ $p.t('cms/aufrufe') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in ranked" :key="row.content_id"
:class="isCurrent(row) ? 'table-primary' : ''">
<td>{{ idx + 1 }}</td>
<td>{{ row.content_id }}</td>
<td>
<a href="#" @click.prevent="onContentClick(row.content_id)">
{{ row.titel || $p.t('cms/ohneTitel') }}
</a>
</td>
<td>{{ row.hits }}</td>
</tr>
</tbody>
</table>
<div v-else class="text-muted">
{{ $p.t('cms/keineAufrufeImZeitraum') }}
</div>
</div>
`
};
+199
View File
@@ -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: `
<div class="p-3" v-if="contentId != null">
<template v-if="!loading && sperre">
<div v-if="sperre.gesperrt_uid !== null"
class="alert"
:class="lockState === 'expired' ? 'alert-warning' : 'alert-info'">
{{ lockInfo }}
</div>
<template v-if="lockState === 'free'">
<button class="btn btn-primary mb-3" @click="lock">
{{ $p.t('cms/zurBearbeitungSperren') }}
</button>
</template>
<template v-else-if="lockState === 'own'">
<button class="btn btn-outline-secondary mb-3" @click="unlock">
{{ $p.t('cms/sperreFreigeben') }}
</button>
<xsd-form
ref="xsdForm"
:schema="schema"
v-model="values"
:disabled="false"
></xsd-form>
<button class="btn btn-primary mt-2 mb-3" @click="save" :disabled="saving">
{{ $p.t('cms/speichern') }}
</button>
</template>
<template v-else-if="lockState === 'expired'">
<button class="btn btn-warning mb-3" @click="lock">
{{ $p.t('cms/sperreUebernehmen') }}
</button>
</template>
<template v-else>
<button v-if="sperre.may_force" class="btn btn-danger mb-3" @click="forceUnlock">
{{ $p.t('cms/freigabeErzwingen') }}
</button>
</template>
<xsd-form
v-if="isRedirect && lockState !== 'own'"
:schema="schema"
:modelValue="values"
:disabled="true"
></xsd-form>
<cms-preview
ref="preview"
:content-id="contentId"
:sprache="sprache"
:version="version"
:template-kurzbz="contentInfo?.template_kurzbz"
:has-cis4-stylesheet="contentInfo?.has_cis4_stylesheet !== false"
:has-contentcomponents="hasContentcomponents"
:sichtbar="sichtbar"
></cms-preview>
</template>
</div>
`
};
+154
View File
@@ -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: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/dieseVersionLoeschen') }}</h5>
<div v-if="isLastVersion" class="alert alert-warning mb-3">
{{ $p.t('cms/letzteVersionNichtLoeschbar') }}
</div>
<div v-else class="mb-3">
<button class="btn btn-outline-danger" @click="deleteVersion">
{{ $p.t('cms/dieseVersionLoeschen') }}
</button>
</div>
<hr>
<h5>{{ $p.t('cms/ganzenContentLoeschen') }}</h5>
<div v-if="loadingUsage" class="mb-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
</div>
<template v-else>
<div v-if="usages.length === 0" class="alert alert-info mb-3">
{{ $p.t('cms/keineVerwendungGefunden') }}
</div>
<div v-else class="mb-3">
<div class="alert alert-warning">
{{ $p.t('cms/contentWirdVerwendetIn') }}
</div>
<ul class="list-group mb-3">
<li v-for="(u, i) in usages" :key="i"
class="list-group-item d-flex justify-content-between
align-items-center gap-3">
<span class="me-auto">
<span class="d-block">{{ u.label || '—' }}</span>
<!-- The value alone says nothing about where the entry
sits. The table is a database name and stays as it is. -->
<code class="small text-muted">{{ u.table }}</code>
</span>
<span v-if="u.table === 'campus.tbl_infoscreen_content'"
class="badge bg-danger text-nowrap">
{{ $p.t('cms/wirdMitgeloescht') }}
</span>
<span v-else class="badge bg-warning text-dark text-nowrap">
{{ $p.t('cms/verhindertLoeschen') }}
</span>
</li>
</ul>
</div>
</template>
<button class="btn btn-danger" @click="deleteContent"
:disabled="hasRestrict || loadingUsage">
{{ $p.t('cms/ganzenContentLoeschen') }}
</button>
</div>
`
};
+332
View File
@@ -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: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/folgendeEintraegeSindUebergeordnet') }}</h5>
<table v-if="parents.length > 0" class="table table-sm table-striped">
<thead>
<tr>
<th>ID</th>
<th>{{ $p.t('cms/titel') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="parent in parents" :key="parent.contentchild_id">
<td>{{ parent.content_id }}</td>
<td>
<a href="#" @click.prevent="onContentClick(parent.content_id)">
{{ parent.titel || $p.t('cms/ohneTitel') }}
</a>
</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="removeParent(parent.contentchild_id)">
{{ $p.t('cms/entfernen') }}
</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="text-muted">
{{ $p.t('cms/keineUebergeordnetenEintraege') }}
</div>
<h5 class="mt-4">{{ $p.t('cms/folgendeEintraegeSindUntergeordnet') }}</h5>
<template v-if="childs.length > 0">
<div class="form-text mb-1">
<i v-if="savingOrder" class="fa-solid fa-spinner fa-spin me-1"></i>
{{ $p.t('cms/ziehenZumSortieren') }}
</div>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $p.t('cms/sortierung') }}</th>
<th>ID</th>
<th>{{ $p.t('cms/titel') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(child, idx) in childs" :key="child.contentchild_id"
draggable="true"
:style="dragIndex === idx ? 'opacity:0.4' : ''"
@dragstart="onDragStart(idx, $event)"
@dragenter.prevent="onDragEnter(idx)"
@dragover.prevent
@drop.prevent="onDrop"
@dragend="onDragEnd">
<td class="text-nowrap">
<i class="fa-solid fa-grip-vertical text-muted me-1"
style="cursor:grab"
:title="$p.t('cms/ziehenZumSortieren')"></i>
{{ idx + 1 }}
<button class="btn btn-sm btn-link p-0 ms-1"
:disabled="isFirst(idx)"
@click="moveSort(child.contentchild_id, 'up')">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="btn btn-sm btn-link p-0 ms-1"
:disabled="isLast(idx)"
@click="moveSort(child.contentchild_id, 'down')">
<i class="fa-solid fa-arrow-down"></i>
</button>
</td>
<td>{{ child.child_content_id }}</td>
<td>
<a href="#" draggable="false"
@click.prevent="onContentClick(child.child_content_id)">
{{ child.titel || $p.t('cms/ohneTitel') }}
</a>
</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="removeChild(child.contentchild_id)">
{{ $p.t('cms/entfernen') }}
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="text-muted">
{{ $p.t('cms/keineUntereintraege') }}
</div>
<div class="d-flex gap-2 align-items-end mt-3">
<select class="form-select" v-model="selectedChild" style="max-width: 500px;">
<option value="" disabled></option>
<option v-for="c in availableChilds" :key="c.content_id"
:value="c.content_id">
{{ childLabel(c) }}
</option>
</select>
<button class="btn btn-primary" @click="addChild" :disabled="!selectedChild">
{{ $p.t('cms/hinzufuegen') }}
</button>
</div>
</div>
`
};
+65
View File
@@ -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: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/versionen') }}</h5>
<div v-for="v in versions" :key="v.version"
class="mb-3 p-2 border rounded"
:class="{ 'border-primary border-2': v.version === version }">
<div class="fw-bold">
{{ $p.t('cms/version') }} {{ v.version }}
<span v-if="!v.sichtbar" class="badge bg-secondary ms-2">
{{ $p.t('cms/unsichtbar') }}
</span>
</div>
<div>{{ $p.t('cms/erstelltAmVon', [formatDate(v.insertamum), v.insertvon]) }}</div>
<div v-if="v.updatevon">
{{ $p.t('cms/letzteAenderungVonAm', [formatDate(v.updateamum), v.updatevon]) }}
</div>
</div>
</div>
`
};
@@ -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: `
<div class="p-3" v-if="contentId != null">
<template v-if="gruppen.length > 0">
<h5>{{ $p.t('cms/folgendeGruppenDuerfenAnsehen') }}</h5>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $p.t('cms/gruppe') }}</th>
<th>{{ $p.t('cms/bezeichnung') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="g in gruppen" :key="g.gruppe_kurzbz">
<td>{{ g.gruppe_kurzbz }}</td>
<td>{{ g.bezeichnung }}</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="removeGruppe(g.gruppe_kurzbz)">
{{ $p.t('cms/entfernen') }}
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="alert alert-warning">
{{ $p.t('cms/seiteDarfVonAllenAngezeigtWerden') }}
</div>
<div class="d-flex gap-2 align-items-end mt-3">
<select class="form-select" v-model="selectedGruppe" style="max-width: 400px;">
<option value="" disabled></option>
<option v-for="g in availableGruppen" :key="g.gruppe_kurzbz"
:value="g.gruppe_kurzbz">
{{ g.gruppe_kurzbz }} — {{ g.bezeichnung }}
</option>
</select>
<button class="btn btn-primary" @click="addGruppe" :disabled="!selectedGruppe">
{{ $p.t('cms/hinzufuegen') }}
</button>
</div>
</div>
`
};
+185
View File
@@ -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: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/giltFuerAlleSprachen') }}</h5>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/vorlage') }}</label>
<div class="col-sm-9">
<select class="form-select" v-model="template_kurzbz"
@change="onTemplateChange">
<option v-for="t in templates" :key="t.template_kurzbz"
:value="t.template_kurzbz">{{ t.bezeichnung }}</option>
</select>
<div v-if="templateChanged" class="text-danger mt-1 fw-bold">
{{ $p.t('cms/vorlageWarnung') }}
</div>
</div>
</div>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/organisationseinheit') }}</label>
<div class="col-sm-9">
<select class="form-select" v-model="oe_kurzbz">
<option v-for="oe in organisationseinheiten" :key="oe.oe_kurzbz"
:value="oe.oe_kurzbz"
:class="{ 'text-muted': !oe.aktiv }">{{ oeLabel(oe) }}</option>
</select>
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="aktiv"
id="prop-aktiv">
<label class="form-check-label" for="prop-aktiv">
{{ $p.t('cms/aktiv') }}
</label>
</div>
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="menu_open"
id="prop-menu-open">
<label class="form-check-label" for="prop-menu-open">
{{ $p.t('cms/menuOpen') }}
</label>
</div>
</div>
</div>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/beschreibung') }}</label>
<div class="col-sm-9">
<textarea class="form-control" rows="3" v-model="beschreibung"></textarea>
</div>
</div>
<hr>
<h5>{{ $p.t('cms/giltFuerDieseVersion') }}</h5>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/titel') }}</label>
<div class="col-sm-9">
<input type="text" class="form-control" v-model="titel" maxlength="256">
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="sichtbar"
id="prop-sichtbar">
<label class="form-check-label" for="prop-sichtbar">
{{ $p.t('cms/sichtbar') }}
</label>
</div>
</div>
</div>
<hr>
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-primary" @click="save" :disabled="saving">
{{ $p.t('cms/speichern') }}
</button>
</div>
</div>
`
};
@@ -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: `
<li class="py-1" :class="{ 'cms-tree-context': muted }">
<div v-for="(a, i) in ancestors" :key="a.content_id"
class="cms-tree-ancestor"
:style="{ marginLeft: (i * 1.1) + 'em' }">
<!-- treeSelect reads entitled and content_id, so an ancestor row is a node
as far as it is concerned. An ancestor of a unit the editor is not
entitled for stays plain text, the same as in the tree itself. -->
<a v-if="a.entitled" href="#"
@click.prevent="treeSelect(a)"
>{{ a.titel || a.content_id }} ({{ a.content_id }})</a>
<span v-else>{{ a.titel || a.content_id }} ({{ a.content_id }})</span>
</div>
<div :style="indent ? { marginLeft: (indent * 1.1) + 'em' } : null">
<span v-if="!flat && hasChildren"
style="cursor:pointer;user-select:none"
@click="treeToggle(node)">
<i class="fa-solid fa-fw"
:class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
</span>
<span v-else-if="!flat" style="display:inline-block;width:1.25em"></span>
<a v-if="node.entitled"
href="#"
class="text-decoration-none"
:style="labelStyle"
@click.prevent="treeSelect(node)">{{ node.titel }} ({{ node.content_id }})</a>
<span v-else :style="!node.aktiv ? 'opacity:0.5' : ''">{{ node.titel }} ({{ node.content_id }})</span>
<i v-if="node.groups && node.groups.length"
class="fa-solid fa-lock fa-sm ms-1 cms-tree-lock"
:title="node.groups.join(', ')"></i>
<span v-if="badge !== null"
class="badge ms-1"
:class="badgeEmpty ? 'bg-light text-muted' : 'bg-secondary'">{{ badge }}</span>
<ul v-if="!flat && isExpanded && hasChildren" class="list-unstyled ms-3">
<cms-tree-node
v-for="child in node.children"
:key="child.content_id"
:node="child"
:active-content-id="activeContentId"
:expanded-keys="expandedKeys"
:flat="flat"
:meta="meta"
:metric="metric"
:muted="muted || indent > 0"
></cms-tree-node>
</ul>
</div>
</li>
`
};
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: `
<div class="p-2">
<menu-switch v-model="menu"></menu-switch>
<tree-filter class="mt-2"
:advanced="advanced"
@filter="onFilter"
@criteria="onCriteria"></tree-filter>
<div class="mt-2 mb-2">
<button class="btn btn-sm btn-outline-secondary me-1"
@click="createEntry(null)">
{{ $p.t('cms/neuenEintragHinzufuegen') }}
</button>
<button class="btn btn-sm btn-outline-secondary"
:disabled="activeContentId == null"
@click="createEntry(activeContentId)">
{{ $p.t('cms/neuenChildEintragHinzufuegen') }}
</button>
</div>
<div v-if="isFlat && !loading" class="d-flex align-items-center gap-2 mb-2">
<div v-if="active" class="form-text m-0">
<i v-if="metaLoading" class="fa-solid fa-spinner fa-spin me-1"></i>
{{ $p.t('cms/nTreffer', [visibleNodes.length]) }}
</div>
<button type="button"
class="btn btn-sm ms-auto"
:class="showHierarchy ? 'btn-secondary' : 'btn-outline-secondary'"
@click="toggleHierarchy">
<i class="fa-solid fa-fw"
:class="ancestorsLoading ? 'fa-spinner fa-spin' : 'fa-sitemap'"></i>
{{ $p.t('cms/hierarchieAnzeigen') }}
</button>
</div>
<div v-if="loading" class="text-center text-muted py-3">
<i class="fa-solid fa-spinner fa-spin"></i>
</div>
<div v-else-if="visibleNodes?.length === 0" class="text-muted py-2">
{{ (filter || active) ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }}
</div>
<ul v-else class="list-unstyled mb-0">
<cms-tree-node
v-for="node in visibleNodes"
:key="node.content_id"
:node="node"
:active-content-id="activeContentId"
:expanded-keys="expandedKeys"
:flat="isFlat && !showPaths"
:meta="meta"
:metric="metric"
:ancestors="pathFor(node.content_id)"
></cms-tree-node>
</ul>
</div>
`
};
@@ -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: `
<div class="btn-group btn-group-sm w-100" role="group">
<button type="button" class="btn"
:class="modelValue === 'content' ? 'btn-primary' : 'btn-outline-secondary'"
@click="select('content')">
{{ $p.t('cms/menuContent') }}
</button>
<button type="button" class="btn"
:class="modelValue === 'news' ? 'btn-primary' : 'btn-outline-secondary'"
@click="select('news')">
{{ $p.t('cms/menuNews') }}
</button>
</div>
`
};
+209
View File
@@ -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: `
<div>
<div class="input-group input-group-sm">
<input type="text"
class="form-control"
:placeholder="$p.t('cms/filterPlatzhalter')"
v-model="text"
@keydown.enter="submit">
<button class="btn btn-outline-secondary" type="button" @click="submit">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
<button v-if="advanced"
type="button"
class="btn btn-sm btn-link px-0 text-decoration-none"
@click="toggle">
<i class="fa-solid fa-fw" :class="open ? 'fa-caret-down' : 'fa-caret-right'"></i>
{{ $p.t('cms/filterErweitert') }}
<span v-if="activeCount" class="badge bg-primary ms-1">{{ activeCount }}</span>
</button>
<div v-if="advanced && open" class="border rounded p-2 mb-2">
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/vorlage') }}</label>
<select class="form-select form-select-sm"
v-model="criteria.template_kurzbz">
<option value="">{{ $p.t('cms/alleVorlagen') }}</option>
<option v-for="t in templates"
:key="t.template_kurzbz"
:value="t.template_kurzbz">{{ t.bezeichnung }}</option>
</select>
</div>
<div class="form-check mb-2">
<input type="checkbox"
class="form-check-input"
id="cms-filter-mine"
v-model="criteria.mine">
<label class="form-check-label" for="cms-filter-mine">
{{ $p.t('cms/nurEigeneBearbeitungen') }}
</label>
</div>
<div class="form-check mb-2">
<input type="checkbox"
class="form-check-input"
id="cms-filter-empty"
v-model="criteria.emptyOnly">
<label class="form-check-label" for="cms-filter-empty">
{{ $p.t('cms/nurLeereInhalte') }}
</label>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortGeaendert') }}</label>
<div class="input-group input-group-sm">
<input type="date" class="form-control"
:title="$p.t('cms/vonDatum')"
v-model="criteria.updatedFrom">
<input type="date" class="form-control"
:title="$p.t('cms/bisDatum')"
v-model="criteria.updatedTo">
</div>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortErstellt') }}</label>
<div class="input-group input-group-sm">
<input type="date" class="form-control"
:title="$p.t('cms/vonDatum')"
v-model="criteria.createdFrom">
<input type="date" class="form-control"
:title="$p.t('cms/bisDatum')"
v-model="criteria.createdTo">
</div>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortChilds') }}</label>
<div class="input-group input-group-sm">
<input type="number" min="0" class="form-control"
:placeholder="$p.t('cms/mindestens')"
v-model="criteria.minChildren">
<input type="number" min="0" class="form-control"
:placeholder="$p.t('cms/hoechstens')"
v-model="criteria.maxChildren">
</div>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortInhaltslaenge') }}</label>
<div class="input-group input-group-sm">
<input type="number" min="0" class="form-control"
:placeholder="$p.t('cms/mindestens')"
v-model="criteria.minLength">
<input type="number" min="0" class="form-control"
:placeholder="$p.t('cms/hoechstens')"
v-model="criteria.maxLength">
</div>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortierung') }}</label>
<select class="form-select form-select-sm"
v-model="criteria.sort">
<option v-for="o in sortOptions" :key="o.value" :value="o.value">
{{ $p.t(o.phrase) }}
</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="reset">
{{ $p.t('cms/filterZuruecksetzen') }}
</button>
</div>
</div>
`
};
@@ -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*/ `
<div class="fhc-contentcomponent-contentchildmenu">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerMenue') }}
</div>
<!-- An empty menu renders nothing. A reader has no use for the note that a
page has no children, and the group rule may have removed every entry. -->
<ul v-else-if="kinder.length" class="list-unstyled mb-0">
<li v-for="kind in kinder" :key="kind.content_id" class="mb-1">
<router-link :to="{ name: 'Content', params: { content_id: kind.content_id } }">
{{ kind.titel }}
</router-link>
</li>
</ul>
</div>
`
};
@@ -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 `<div v-html="content">`. 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*/ `
<div ref="root" v-html="content"></div>
<Teleport v-for="marker in markers" :key="marker.key" :to="marker.el">
<component :is="resolve(marker.name)" v-bind="marker.props"></component>
</Teleport>
`
};
@@ -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*/ `
<div class="fhc-contentcomponent-dmsdokumente">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerDokumente') }}
</div>
<!-- An empty list renders nothing. Every named document may have dropped out
through the category rule, and that is not the reader's business. -->
<dokument-liste
v-else-if="dokumente.length"
:dokumente="dokumente"
></dokument-liste>
</div>
`
};
@@ -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*/ `
<div class="fhc-contentcomponent-dmsliste">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerDokumente') }}
</div>
<!-- A reader without the entitlement sees nothing at all, not a hint that
something is hidden here. -->
<template v-else-if="zugriff">
<div v-if="!dokumente.length" class="text-muted">
{{ $p.t('cms/ccKeineDokumente') }}
</div>
<dokument-liste v-else :dokumente="dokumente"></dokument-liste>
</template>
</div>
`
};
@@ -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*/ `
<ul class="list-unstyled mb-0">
<li v-for="dokument in dokumente" :key="dokument.dms_id" class="mb-2">
<a :href="href(dokument)" target="_blank" rel="noopener">
<i class="fa-regular" :class="icon(dokument.mimetype)"></i>
{{ dokument.name }}
</a>
<span v-if="dokument.geaendert" class="text-muted small ms-2">
{{ datum(dokument.geaendert) }}
</span>
<div v-if="dokument.beschreibung" class="text-muted small">
{{ dokument.beschreibung }}
</div>
</li>
</ul>
`
};
@@ -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*/ `
<div class="fhc-contentcomponent-oepersonen">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerPersonen') }}
</div>
<div v-else-if="!personen.length" class="text-muted">
{{ $p.t('cms/ccKeinePersonen') }}
</div>
<template v-else>
<person-block
v-for="person in personen"
:key="person.uid"
:uid="person.uid"
:name="person.name"
:funktion="person.funktion"
:telefon="person.telefon"
:email="person.email"
:ort="person.ort"
:foto="person.foto"
></person-block>
</template>
</div>
`
};
@@ -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*/ `
<div class="fhc-contentcomponent-person mb-3">
<img
v-if="fotoQuelle"
:src="fotoQuelle"
alt=""
class="mb-2"
style="width: 110px; height: auto; object-fit: scale-down;"
>
<div>
<router-link v-if="uid" :to="{ name: 'ProfilView', params: { uid: uid } }">
{{ name }}
</router-link>
<span v-else>{{ name }}</span>
</div>
<div v-if="funktion">{{ funktion }}</div>
<div v-if="telefon">
T: <a :href="telefonLink">{{ telefon }}</a>
</div>
<div v-if="ort">R: {{ ort }}</div>
<div v-if="email">
E: <a :href="'mailto:' + email">{{ email }}</a>
</div>
</div>
`
};
@@ -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*/ `
<div class="fhc-contentcomponent-personkontakt">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerPerson') }}
</div>
<person-block
v-else-if="person"
:uid="person.uid"
:name="person.name"
:funktion="funktion"
:telefon="person.telefon"
:email="person.email"
:ort="person.ort"
:foto="person.foto"
></person-block>
</div>
`
};
@@ -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
<div class="fhc-contentcomponent" data-fhc-component="dms-liste" data-kategorie-kurzbz="rl"></div>
```
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-<name>`.
- `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.
@@ -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:
*
* <div class="fhc-contentcomponent" data-fhc-component="NAME" data-PROP="VALUE"></div>
*
* 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;
}
@@ -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.');
}
@@ -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;
}
@@ -256,6 +256,7 @@ export default {
<div id="FhcCarouselContainer" style="height: 100%;" ref="carousel" class="carousel slide fhc-carousel ms-2" data-bs-interval="false">
<div class="carousel-inner" ref="carouselInner" style="height: 100%; max-width: 100%;">
<!-- No contentcomponent-host on purpose: the carousel shows many teasers, and every component would start its own fetch. -->
<div ref="carouselItems" v-for="(news, index) in newsList" class="carousel-item fhc-news-card-item" style="overflow-y: auto; overflow-x: hidden; height: 100%;" :id="'card-'+news.news_id" v-html="news.content_obj.content"/>
</div>
<button @click="setPrev" style="z-index: 100; overflow: hidden; margin-left: 4px; width:35px;" data-bs-target="#FhcCarouselContainer" class="carousel-control-prev" type="button">
+34
View File
@@ -0,0 +1,34 @@
import InViewTooltip from '../directives/inViewTooltip.js';
/**
* ? icon with a help text in the in-view tooltip. The panel stays inside the viewport, also at the
* screen edge, in a modal and on a phone: a plain tooltip ran out of the screen there.
*
* Help texts are long, so the panel is sticky by default: it sticks after a hover of 1.4 s or a click,
* a tap toggles it on touch. sticky: false makes it a plain hover tooltip.
*
* The view needs public/css/components/inViewTooltip.css.
*
* Usage: <in-view-help :text="$p.t('category/phrase')" button-class="fs-5 text-body-secondary"></in-view-help>
*/
export default {
name: 'InViewHelp',
directives: {
tooltip: InViewTooltip
},
props: {
text: { type: String, default: '' },
sticky: { type: Boolean, default: true },
buttonClass: { type: [String, Array, Object], default: '' }
},
template: /*html*/ `
<button
type="button"
class="btn btn-link p-0 fhc-in-view-help-btn"
:class="buttonClass"
:aria-label="$p.t('ui/hilfe')"
v-tooltip="{ value: text, sticky }"
>
<i class="fa fa-circle-question" aria-hidden="true"></i>
</button>`
};
+20 -6
View File
@@ -106,6 +106,13 @@ export const CoreFilterCmpt = {
type: Array,
default: [],
},
// fields of the columns a preset is allowed to handle. Give the constant
// columns here if the table builds dynamic columns at runtime. An empty
// array gives the preset every column of the table.
presetColumns: {
type: Array,
default: [],
},
},
data: function () {
return {
@@ -942,6 +949,10 @@ export const CoreFilterCmpt = {
return res;
}, {});
},
isColumnInPresetScope(columnField) {
if (!this.$props.presetColumns.length) return true;
return this.$props.presetColumns.includes(columnField);
},
afterTablePresetApplied(preset) {
this.selectedFields = [...preset.displayedColumns];
this.appliedPreset = { ...preset };
@@ -973,6 +984,7 @@ export const CoreFilterCmpt = {
const displayedColumns = this.tabulator.columnManager.columns
.filter((column) => column.field !== "collapse")
.filter((column) => this.isColumnInPresetScope(column.field))
.filter((column) => column.visible)
.map((column) => column.field);
@@ -1029,12 +1041,13 @@ export const CoreFilterCmpt = {
});
const activeSort = this.tabulator.getSorters()[0];
const sort = activeSort
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
const sort =
activeSort && this.isColumnInPresetScope(activeSort.field)
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
if (
sort?.column !== this.appliedPreset.sort?.column ||
sort?.direction !== this.appliedPreset.sort?.direction
@@ -1148,6 +1161,7 @@ export const CoreFilterCmpt = {
:presetsId="$props.presetsId"
:tabulator="tabulator"
:generalPresets="$props.generalPresets"
:presetColumns="$props.presetColumns"
ref="tablePresets"
class="card-body collapse"
></table-presets>
@@ -26,6 +26,9 @@ export default {
presetsId: { type: String },
tabulator: { type: Object },
generalPresets: { type: Array, default: [] },
// fields of the columns the preset is allowed to handle. An empty array
// gives the preset every column of the table.
presetColumns: { type: Array, default: [] },
},
emits: ["tablePresetApplied"],
data: function () {
@@ -162,6 +165,10 @@ export default {
},
},
methods: {
isInPresetScope(columnField) {
if (!this.$props.presetColumns.length) return true;
return this.$props.presetColumns.includes(columnField);
},
getPresetIndex() {
return this.presetIndexBeingApplied;
},
@@ -227,9 +234,9 @@ export default {
}
},
generateNewPreset() {
const columns = this.tabulator.columnManager.columns.filter(
(column) => column.field !== "collapse",
);
const columns = this.tabulator.columnManager.columns
.filter((column) => column.field !== "collapse")
.filter((column) => this.isInPresetScope(column.field));
const displayedColumns = columns
.filter((column) => column.visible)
@@ -251,12 +258,13 @@ export default {
);
const activeSort = this.tabulator.getSorters()[0];
const sort = activeSort
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
const sort =
activeSort && this.isInPresetScope(activeSort.field)
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
this.newPreset = {
displayedColumns,
@@ -325,8 +333,11 @@ export default {
preset.displayedColumns.length;
preset.displayedColumns = preset.displayedColumns.filter(
(columnField) => {
return validColumns.some(
(column) => column.field === columnField,
return (
this.isInPresetScope(columnField) &&
validColumns.some(
(column) => column.field === columnField,
)
);
},
);
@@ -365,10 +376,11 @@ export default {
if (preset.sort) {
if (
!this.isInPresetScope(preset.sort.column) ||
!validColumns.some(
(column) =>
column.field === preset.sort.column &&
column.headerSort,
column.headerSort !== false,
)
) {
preset.sort = null;
@@ -407,7 +419,10 @@ export default {
window.localStorage["tabulatorPreset-" + this.$props.presetsId] =
preset.id ? "custom:" + preset.id : "general:" + preset.name;
this.$props.tabulator.modules.tablePresets.applyPreset(preset);
this.$props.tabulator.modules.tablePresets.applyPreset(
preset,
this.$props.presetColumns,
);
this.$emit("tablePresetApplied", { preset });
+352
View File
@@ -0,0 +1,352 @@
/**
* v-tooltip with a panel that stays in view, also at the screen edge, in a modal and on a phone.
*
* - The panel is fixed and stays inside the viewport. It opens below the anchor, above it when
* it fits only there or that side has more space. It grows away from the nearer viewport side.
* - On an anchor wider than the panel, e.g. a long table cell, it opens at the mouse, not at the anchor edge.
* - One panel at a time, appended to the body. A modal or a scrolling container cannot clip it.
* - A mouse hover or a keyboard focus opens it. Escape, a click outside or a scroll closes it.
*
* Sticky, for several sentences or a list: a short label is read before the hover pause ends, a long text is not.
* - After HOLD_DELAY_MS + HOLD_FILL_MS of hovering the panel sticks, the border fills up in the end.
* A click on the anchor makes it stick at once. Touch has no hover: a tap toggles the sticky panel.
* - A sticky panel stays until a click outside, Escape, a scroll or an opening bootstrap modal.
*
* The view needs public/css/components/inViewTooltip.css.
*
* Usage examples:
*
* 1) Directive, registered for the app or the component:
* app.directive('tooltip', InViewTooltip) or directives: { tooltip: InViewTooltip }
* <button v-tooltip="label"> a short label, hover only
* <span v-tooltip.sticky="longText"> several sentences or a list
* v-tooltip="{ value: text, disabled: bool, sticky: bool }", the modifier .top prefers the place above
*
* 2) Without a directive, e.g. a tabulator cell. The content is a text, a DOM node or a function:
* { event: 'cellMouseEnter', handler: (e, cell) => hoverTooltip(cell.getElement(), () => buildNode(cell), { delay: 300, sticky: true, closeOnClick: true }) },
* { event: 'cellMouseLeave', handler: (e, cell) => hideTooltip(cell.getElement()) }
*
* 3) A ? icon with a help text: components/InViewHelp.js
*/
// hover time until a sticky panel sticks: a pause without motion filters out a pointer that only passes by,
// then a short fill shows the pin. A long fill would draw the eye away from the text
const HOLD_DELAY_MS = 1000
const HOLD_FILL_MS = 400
const HIDE_DELAY_MS = 150 // the pointer can cross the gap from the anchor into the panel
const WATCH_MS = 250 // an anchor can leave the page without an unmount, e.g. in a hidden keep-alive tab
const PANEL_MAX_WIDTH = 512 // px
const PANEL_MARGIN = 16 // px between the panel and the viewport edge
const PANEL_GAP = 6 // px between the anchor and the panel
const PANEL_RADIUS = 5 // px, the inner radius of the panel border. The progress border follows it
const PANEL_POINTER_INSET = 16 // px, the horizontal padding of the panel: the text starts below the mouse
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
let active = null // the open panel, only one at a time
let pending = null // a delayed show
let panelCount = 0
// the last pointer. Its type tells a tap from a mouse also for the mouse events of tabulator, a tap fires
// emulated mouse events too: pointerover fires before them. Its place puts the panel next to the mouse
const pointer = { type: 'mouse', x: null, y: null }
const trackPointer = event => {
pointer.type = event.pointerType
pointer.x = event.clientX
pointer.y = event.clientY
}
for (const name of ['pointerover', 'pointermove', 'pointerdown'])
document.addEventListener(name, trackPointer, { capture: true, passive: true })
/**
* Opens the panel for an anchor element.
*
* content: a text, a DOM node or a function that returns one of them.
* options: prefer ('bottom' | 'top'), delay (ms), sticky (the hover timer pins the panel), pinned (sticks at once),
* closeOnClick (a click on the anchor closes the panel, e.g. a table cell with a click action)
*/
export function showTooltip(anchor, content, options = {}) {
cancelPending()
if (!options.delay) return open(anchor, content, options)
pending = { anchor, timer: setTimeout(() => { pending = null; open(anchor, content, options) }, options.delay) }
}
// for mouse events without a pointer type, e.g. the cell events of tabulator
export function hoverTooltip(anchor, content, options) {
if (pointer.type === 'mouse') showTooltip(anchor, content, options)
}
// closes the panel of the anchor after HIDE_DELAY_MS, a sticky panel stays
export function hideTooltip(anchor) {
if (pending?.anchor === anchor) cancelPending()
if (active?.anchor !== anchor || active.pinned) return
clearTimeout(active.hideTimer)
active.hideTimer = setTimeout(close, HIDE_DELAY_MS)
}
// closes the open panel, with an anchor only the panel of that anchor
export function closeTooltip(anchor) {
if (!anchor || pending?.anchor === anchor) cancelPending()
if (!anchor || active?.anchor === anchor) close()
}
function isTooltipPinned(anchor) {
return active?.anchor === anchor && active.pinned
}
// new content for an open panel, an empty text closes it
function updateTooltip(anchor, content) {
if (active?.anchor !== anchor) return
if (!content) return close()
render(active.panel, content)
place()
}
function open(anchor, content, { prefer = 'bottom', sticky = false, pinned = false, closeOnClick = false }) {
if (active?.anchor === anchor) {
clearTimeout(active.hideTimer)
if (pinned) pin()
return
}
close()
sticky = sticky || pinned
const panel = document.createElement('div')
panel.id = 'fhc-in-view-tooltip-' + ++panelCount
panel.className = 'fhc-in-view-tooltip'
panel.setAttribute('role', 'tooltip')
panel.innerHTML = '<div class="fhc-in-view-tooltip-body"></div>'
if (sticky) {
panel.style.setProperty('--fhc-in-view-tooltip-hold-delay', HOLD_DELAY_MS + 'ms')
panel.style.setProperty('--fhc-in-view-tooltip-hold-fill', HOLD_FILL_MS + 'ms')
panel.insertAdjacentHTML('beforeend', '<svg class="fhc-in-view-tooltip-progress" aria-hidden="true">'
+ '<rect width="100%" height="100%" rx="' + PANEL_RADIUS + '"></rect></svg>')
}
render(panel, content)
document.body.appendChild(panel)
// the pointer at the opening, while it is over the anchor. A keyboard focus has none
const rect = anchor.getBoundingClientRect()
const overAnchor = pointer.x !== null && pointer.x >= rect.left && pointer.x <= rect.right
&& pointer.y >= rect.top && pointer.y <= rect.bottom
active = {
anchor,
panel,
prefer,
pointerX: overAnchor ? pointer.x : null,
sticky,
closeOnClick,
pinned: false,
describedBy: anchor.getAttribute('aria-describedby'),
holdTimer: null,
hideTimer: null,
watchTimer: setInterval(() => {
if (!anchor.isConnected || !anchor.getClientRects().length) close()
}, WATCH_MS)
}
place()
anchor.setAttribute('aria-describedby', panel.id)
if (pinned) {
pin()
} else if (sticky) {
panel.classList.add('fhc-in-view-tooltip--holding')
active.holdTimer = setTimeout(pin, HOLD_DELAY_MS + HOLD_FILL_MS)
}
panel.addEventListener('pointerenter', onPanelEnter)
panel.addEventListener('pointerleave', onPanelLeave)
panel.addEventListener('click', onPanelClick)
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('keydown', onKeyDown, true)
// an anchor often opens a modal on click, the sticky panel must not cover it
document.addEventListener('show.bs.modal', close)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', place)
}
function pin() {
if (!active || active.pinned || !active.sticky) return
clearTimeout(active.holdTimer)
clearTimeout(active.hideTimer)
active.pinned = true
active.panel.classList.remove('fhc-in-view-tooltip--holding')
active.panel.classList.add('fhc-in-view-tooltip--pinned')
}
function close() {
if (!active) return
const { anchor, panel, describedBy } = active
clearTimeout(active.holdTimer)
clearTimeout(active.hideTimer)
clearInterval(active.watchTimer)
active = null
panel.remove()
if (describedBy) anchor.setAttribute('aria-describedby', describedBy)
else anchor.removeAttribute('aria-describedby')
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('show.bs.modal', close)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', place)
}
function cancelPending() {
if (!pending) return
clearTimeout(pending.timer)
pending = null
}
function render(panel, content) {
if (typeof content === 'function') content = content()
const body = panel.querySelector('.fhc-in-view-tooltip-body')
if (content instanceof Node) body.replaceChildren(content)
else body.textContent = content
}
function place() {
if (!active) return
const { anchor, panel, prefer } = active
const rect = anchor.getBoundingClientRect()
const viewWidth = document.documentElement.clientWidth
const viewHeight = document.documentElement.clientHeight
// measure the natural size first
panel.style.maxWidth = Math.min(PANEL_MAX_WIDTH, viewWidth - 2 * PANEL_MARGIN) + 'px'
panel.style.maxHeight = ''
panel.style.left = '0px'
panel.style.top = '0px'
panel.style.bottom = ''
const width = panel.offsetWidth
const height = panel.offsetHeight
const space = {
bottom: viewHeight - rect.bottom - PANEL_GAP - PANEL_MARGIN,
top: rect.top - PANEL_GAP - PANEL_MARGIN
}
const other = prefer === 'top' ? 'bottom' : 'top'
let side = prefer
if (space[prefer] < height) side = space[other] >= height || space[other] > space[prefer] ? other : prefer
// a panel at least as wide as the anchor starts at the anchor edge, grows away from the nearer viewport side
// and spans the whole anchor. On a wider anchor that edge can be far from the mouse: the text starts below
// the pointer, the clamp moves the panel to the left only at the right viewport edge
const { pointerX } = active
let left
if (pointerX === null || width >= rect.width) left = rect.left + rect.width / 2 < viewWidth / 2 ? rect.left : rect.right - width
else left = pointerX - PANEL_POINTER_INSET
panel.style.left = clamp(left, PANEL_MARGIN, viewWidth - PANEL_MARGIN - width) + 'px'
panel.style.maxHeight = space[side] + 'px'
if (side === 'bottom') {
panel.style.top = rect.bottom + PANEL_GAP + 'px'
} else {
panel.style.top = ''
panel.style.bottom = viewHeight - rect.top + PANEL_GAP + 'px'
}
if (!active.sticky) return
// the dash of the progress border is as long as the rounded rect along the inner edge of the 1 px border.
// Rounded up, else a gap stays at its start: clientWidth drops the fraction of the width
const box = panel.getBoundingClientRect()
const perimeter = 2 * (box.width + box.height - 4) - (8 - 2 * Math.PI) * PANEL_RADIUS
panel.style.setProperty('--fhc-in-view-tooltip-perimeter', Math.ceil(perimeter) + 1)
}
function onPanelEnter() {
cancelPending()
clearTimeout(active.hideTimer)
}
function onPanelLeave(event) {
if (event.pointerType === 'mouse') hideTooltip(active.anchor)
}
// a click into the panel closes it, a text selection keeps it
function onPanelClick() {
if (!String(window.getSelection())) close()
}
function onPointerDown(event) {
const { anchor, panel, closeOnClick } = active
if (panel.contains(event.target)) return
if (anchor.contains(event.target) && !closeOnClick) return
cancelPending()
close()
}
// Escape closes only the panel, not a modal around the anchor
function onKeyDown(event) {
if (event.key !== 'Escape') return
event.stopPropagation()
close()
}
// the panel is fixed, a scroll moves the anchor away from it. A scroll inside the panel keeps it
function onScroll(event) {
if (!active.panel.contains(event.target)) close()
}
// reads the binding: "text" or { value, disabled, sticky }, the modifiers .sticky and .top
function read(state, binding) {
const value = binding.value
const isObject = value && typeof value === 'object'
state.text = (isObject ? (value.disabled ? '' : value.value) : value) ?? ''
state.sticky = !!(binding.modifiers.sticky || isObject && value.sticky)
state.prefer = binding.modifiers.top ? 'top' : 'bottom'
}
export default {
mounted(el, binding) {
const state = el._fhcInViewTooltip = { pointerType: 'mouse' }
read(state, binding)
const show = options => {
if (state.text) showTooltip(el, state.text, { prefer: state.prefer, sticky: state.sticky, ...options })
}
state.listeners = {
pointerenter: event => { if (event.pointerType === 'mouse') show() },
pointerleave: event => { if (event.pointerType === 'mouse') hideTooltip(el) },
pointerdown: event => { state.pointerType = event.pointerType },
// a mouse click or a key pins a sticky panel at once, a tap toggles it
click: () => {
if (!state.sticky) return
if (state.pointerType !== 'mouse' && isTooltipPinned(el)) closeTooltip(el)
else show({ pinned: true })
state.pointerType = 'mouse' // a key click has no pointerdown
},
focusin: event => { if (event.target.matches(':focus-visible')) show() },
focusout: () => hideTooltip(el)
}
// the click captures: it pins the panel before the click action of the anchor runs, so a modal
// that this action opens closes the panel again. The pointer events must not capture the children
for (const [name, listener] of Object.entries(state.listeners)) el.addEventListener(name, listener, name === 'click')
},
updated(el, binding) {
const state = el._fhcInViewTooltip
const text = state.text
read(state, binding)
if (state.text !== text) updateTooltip(el, state.text)
},
beforeUnmount(el) {
const state = el._fhcInViewTooltip
closeTooltip(el)
for (const [name, listener] of Object.entries(state.listeners)) el.removeEventListener(name, listener, name === 'click')
delete el._fhcInViewTooltip
}
}
@@ -8,27 +8,36 @@ export default class TablePresetsModule extends Module {
super(table);
}
applyPreset(preset) {
let columnsConfig = this.table.options.columns;
let reorderedColumnsConfig = [];
preset.displayedColumns.forEach((columnField) => {
let columnConfig = columnsConfig.find(
(column) => column.field === columnField,
);
if (!columnConfig) return;
columnConfig.visible = true;
reorderedColumnsConfig.push(columnConfig);
});
let hiddenColumnsConfig = columnsConfig
.filter((column) => !preset.displayedColumns.includes(column.field))
.map((column) => {
column.visible = false;
return column;
// presetColumns limits the preset to the given column fields. A table with dynamic
// columns gives the fields of its constant columns here.
applyPreset(preset, presetColumns = []) {
if (presetColumns.length) {
this.applyColumnsInScope(preset, presetColumns);
} else {
let columnsConfig = this.table.options.columns;
let reorderedColumnsConfig = [];
preset.displayedColumns.forEach((columnField) => {
let columnConfig = columnsConfig.find(
(column) => column.field === columnField,
);
if (!columnConfig) return;
columnConfig.visible = true;
reorderedColumnsConfig.push(columnConfig);
});
this.table.setColumns(
reorderedColumnsConfig.concat(hiddenColumnsConfig),
);
let hiddenColumnsConfig = columnsConfig
.filter(
(column) =>
!preset.displayedColumns.includes(column.field),
)
.map((column) => {
column.visible = false;
return column;
});
this.table.setColumns(
reorderedColumnsConfig.concat(hiddenColumnsConfig),
);
}
this.table.clearHeaderFilter();
if (preset.headerFilters) {
@@ -61,4 +70,44 @@ export default class TablePresetsModule extends Module {
);
}
}
// The preset orders and hides the columns of the scope only. A column outside the
// scope keeps its position, because the table does not show it in every state.
applyColumnsInScope(preset, presetColumns) {
const displayedColumns = preset.displayedColumns ?? [];
// a displayed column takes the rank of the preset. A column that the preset
// hides has no rank and keeps the current order at the end of its block.
const rank = (columnConfig) => {
const index = displayedColumns.indexOf(columnConfig.field);
return index === -1 ? displayedColumns.length : index;
};
let columnsConfig = [];
let scopeBlock = [];
// the sort runs for each block of neighbouring scope columns. This way a scope
// column never moves over a column outside the scope.
const addScopeBlock = () => {
scopeBlock.sort((a, b) => rank(a) - rank(b));
columnsConfig = columnsConfig.concat(scopeBlock);
scopeBlock = [];
};
this.table.getColumnDefinitions().forEach((columnConfig) => {
if (!presetColumns.includes(columnConfig.field)) {
addScopeBlock();
columnsConfig.push(columnConfig);
return;
}
columnConfig.visible = displayedColumns.includes(
columnConfig.field,
);
scopeBlock.push(columnConfig);
});
addScopeBlock();
this.table.setColumns(columnsConfig);
}
}
-1
View File
@@ -27,7 +27,6 @@ require_once('dbupdate_3.4/example.php');
require_once('dbupdate_3.4/example2.php');
...
*/
//require_once('dbupdate_3.4/25003_notenimport_nachpruefung.php');
require_once('dbupdate_3.4/dbupdate_dashboard.php');
require_once('dbupdate_3.4/26173_index_webservicelog.php');
require_once('dbupdate_3.4/24682_reihungstest_zugangscode_fuer_login.php');
+6123 -60
View File
File diff suppressed because it is too large Load Diff