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;

This commit is contained in:
Johann Hoffmann
2026-09-08 11:42:39 +02:00
parent 086d20c1c9
commit d1bd77efb9
45 changed files with 3715 additions and 484 deletions
+8 -1
View File
@@ -10,5 +10,12 @@ $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;
// Default period of the tree click count in months, when the caller sends none.
// 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;
+7 -1
View File
@@ -7,11 +7,17 @@ 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()
{
$this->load->view('Cms.php');
// 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')
]);
}
}
+79 -17
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
@@ -81,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);
@@ -218,28 +225,46 @@ class Cms extends FHCAPI_Controller
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;
// '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_kurzbz);
$rows = $this->getDataOrTerminateWithError($rows) ?: array();
// The model returns the aktiv flag but does not filter on it.
$rows = array_values(array_filter($rows, function ($row) {
return $row->aktiv;
}));
// 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 ($rows as $row)
foreach ($oeRows as $oe)
{
$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;
// '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));
@@ -292,8 +317,28 @@ class Cms extends FHCAPI_Controller
$dokumente = array();
if ($erlaubt)
{
$dokumente = $this->DmsModel->getKategorieDokumente($kategorie_kurzbz);
$dokumente = $this->getDataOrTerminateWithError($dokumente) ?: array();
// 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(
@@ -451,6 +496,23 @@ class Cms extends FHCAPI_Controller
* @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');
@@ -20,7 +20,10 @@ class CmsAdmin extends FHCAPI_Controller
'getMitarbeiter' => ['basis/cms:r'],
'getDmsKategorieDokumente' => ['basis/cms:r'],
'getUsage' => ['basis/cms:r'],
'getClickCounts' => ['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'],
@@ -52,7 +55,7 @@ class CmsAdmin extends FHCAPI_Controller
$filter = $this->input->get('filter', TRUE);
$entitledResult = $this->cmsadminlib->getEntitledOe();
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult);
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? [];
$nodes = [];
@@ -226,6 +229,10 @@ class CmsAdmin extends FHCAPI_Controller
$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);
}
@@ -253,11 +260,161 @@ class CmsAdmin extends FHCAPI_Controller
$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;
/**
* Views per content for the tree ranking. Empty if LOG_CONTENT is off.
* Ancestors of the given contents, keyed by content_id and ordered from the top down.
*/
public function getClickCounts()
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');
@@ -265,29 +422,32 @@ class CmsAdmin extends FHCAPI_Controller
$this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('system/Webservicelog_model', 'WebservicelogModel');
$this->config->load('cms');
$months = $this->input->get('months');
if ($months === null || $months === '')
$months = $this->config->item('clickstats_months');
$months = (int) $months;
list($months, $since) = $this->clickstatsPeriod();
// 0 counts the whole log. That is the slowest case and stays an explicit choice.
$since = ($months > 0)
? date('Y-m-d H:i:s', strtotime('-' . $months . ' months'))
: null;
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->WebservicelogModel->getContentClickCounts($since);
$result = $this->WebservicelogModel->getContentClickCounts(
$since, $sprache, (int) $this->config->item('clickstats_limit')
);
$rows = $this->getDataOrTerminateWithError($result);
$counts = [];
$ranked = [];
foreach ((array) $rows as $row)
$counts[(int) $row->request_id] = (int) $row->hits;
{
$ranked[] = [
'content_id' => (int) $row->content_id,
'titel' => $row->titel,
'hits' => (int) $row->hits
];
}
$this->terminateWithSuccess([
'since' => $since,
'months' => $months,
'counts' => $counts
'ranked' => $ranked
]);
}
@@ -316,9 +476,7 @@ class CmsAdmin extends FHCAPI_Controller
{
$this->load->model('system/Sprache_model', 'SpracheModel');
$this->SpracheModel->addSelect('sprache, bezeichnung');
// DEVIATION: admin.php offers every row of tbl_sprache. The column content marks a
// language as relevant for the content language choice, so a retired language stays
// out of the list. Phrasen.php filters the same table the same way.
$result = $this->SpracheModel->loadWhere(['content' => true]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
@@ -10,12 +10,14 @@ class CmsAdminStruktur extends FHCAPI_Controller
'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']
'putChildSort' => ['basis/cms:rw'],
'putChildOrder' => ['basis/cms:rw']
]);
$this->load->library('PermissionLib');
@@ -64,6 +66,24 @@ class CmsAdminStruktur extends FHCAPI_Controller
$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');
@@ -146,10 +166,7 @@ class CmsAdminStruktur extends FHCAPI_Controller
$sortResult = $this->ContentchildModel->getMaxSort($content_id);
$sort = $this->getDataOrTerminateWithError($sortResult) + 1;
// LEGACY-QUIRK: tbl_contentchild has no unique index on (content_id,
// child_content_id). The legacy code does not check for a duplicate, so a child can be
// attached more than once. Kept as a functional copy. See section 3 of the contract.
$result = $this->ContentchildModel->insert([
'content_id' => $content_id,
'child_content_id' => $this->input->post('child_content_id'),
@@ -191,10 +208,58 @@ class CmsAdminStruktur extends FHCAPI_Controller
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'),
$this->input->post('direction')
$direction
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
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);
}
}
+20 -31
View File
@@ -35,10 +35,7 @@ class CmsAdminLib
return success($oe);
}
// LEGACY-QUIRK: the legacy code checks the organisational unit only when it loads the
// page. It does not check on each write. This method exists, but it is called only
// where the legacy code calls it. See Q7 in the contract.
/**
* @param int $content_id
* @return object success(bool)
@@ -188,9 +185,7 @@ class CmsAdminLib
'sichtbar' => true,
'titel' => $source->titel,
'content' => $source->content,
// The review feature is gone from this admin, but the columns stay and the
// legacy admin still writes them. A copied row must not inherit the review
// stamp of its source, so we clear it here.
// The review feature is gone from this admin, but the columns stay
'reviewvon' => null,
'reviewamum' => null,
'gesperrt_uid' => null,
@@ -232,9 +227,7 @@ class CmsAdminLib
'sichtbar' => false,
'titel' => $source->titel,
'content' => $source->content,
// The review feature is gone from this admin, but the columns stay and the
// legacy admin still writes them. A copied row must not inherit the review
// stamp of its source, so we clear it here.
// The review feature is gone from this admin, but the columns stay
'reviewvon' => null,
'reviewamum' => null,
'gesperrt_uid' => null,
@@ -250,7 +243,9 @@ class CmsAdminLib
}
/**
* @param array $daten fields from contract 7.7
* 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)
@@ -277,11 +272,11 @@ class CmsAdminLib
'updatevon' => $uid
]);
// LEGACY-QUIRK: prefs_save sets updateamum and updatevon in tbl_content only.
// It does not touch tbl_contentsprache. Kept as a functional copy.
$this->ci->ContentspracheModel->update($row->contentsprache_id, [
'titel' => $daten['titel'],
'sichtbar' => $daten['sichtbar']
'sichtbar' => $daten['sichtbar'],
'updateamum' => $now,
'updatevon' => $uid
]);
$this->ci->db->trans_complete();
@@ -292,9 +287,9 @@ class CmsAdminLib
return success(true);
}
// LEGACY-QUIRK: the legacy XSDFormPrinter_XML branch checks neither the permission
// 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. See Q3 in the contract.
// itself is unprotected.
/**
* @param int $content_id
* @param string $sprache
@@ -342,9 +337,7 @@ class CmsAdminLib
return success(true);
}
// DEVIATION: content::sperren overwrites a live lock without a check (Q2). Locking now
// refuses a live foreign lock and takes over only an expired one.
/**
* Locks a version for the current user, or takes over an expired lock.
* @param int $contentsprache_id
@@ -373,8 +366,7 @@ class CmsAdminLib
}
$this->ci->db->trans_start();
// Close every open entry: a take-over ends the previous one, and the legacy leaks them.
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id);
$logResult = $this->ci->ContentlogModel->insert([
@@ -399,8 +391,8 @@ class CmsAdminLib
return success(true);
}
// DEVIATION: content::freigabeUser releases ALL locks of the user (Q1). Releasing now
// ends one lock. The uid predicate stops a user releasing a lock of somebody else.
// 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
@@ -446,7 +438,7 @@ class CmsAdminLib
* @param int $content_id
* @param string $sprache
* @param int $version
* @return object success(array) lock state per contract 7.8
* @return object success(array) gesperrt_uid, start, expires, own, expired, may_force
*/
public function getLockState($content_id, $sprache, $version)
{
@@ -455,8 +447,8 @@ class CmsAdminLib
return $versionResult;
$row = getData($versionResult);
// LEGACY-QUIRK: admin.php inserts an empty string, releasing writes NULL. Both mean
// free. The contract reports NULL.
// 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;
@@ -495,8 +487,7 @@ class CmsAdminLib
return date('Y-m-d H:i:s', strtotime($logEntry->start) + $ttlHours * 3600);
}
// DEVIATION: the legacy holds a lock until its owner or a superuser releases it, so a
// forgotten lock blocks a page forever. Locks now age out after lock_ttl_hours.
// 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
@@ -512,9 +503,7 @@ class CmsAdminLib
return $expires === null || strtotime($expires) < time();
}
// DEVIATION: admin.php ignores the return value of deleteContent and tells the user
// nothing. This code reports the error. See Q5 in the contract.
/**
* @param int $content_id
* @return object success(true) or error
+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;
@@ -398,6 +398,63 @@ class Content_model extends DB_Model
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
@@ -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
@@ -34,6 +37,28 @@ class Contentchild_model extends DB_Model
}
/**
* 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.
*
@@ -185,8 +210,10 @@ class Contentchild_model extends DB_Model
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($direction === 'up' ? 'cms/bereitsGanzOben' : 'cms/bereitsGanzUnten');
return error(self::NO_NEIGHBOUR);
$neighbour = $neighbourData[0];
@@ -210,6 +237,111 @@ class Contentchild_model extends DB_Model
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
@@ -20,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)
{
@@ -32,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
+35
View File
@@ -232,4 +232,39 @@ class Dms_model extends DB_Model
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);
}
}
@@ -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]);
}
}
@@ -14,18 +14,59 @@ class Webservicelog_model extends DB_Model
}
/**
* Counts the content views per content since a date.
* 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 = null)
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 request_id, COUNT(*) AS hits
SELECT COUNT(*) AS hits
FROM system.tbl_webservicelog
WHERE webservicetyp_kurzbz = ?
WHERE webservicetyp_kurzbz = ? AND request_id = ?
';
$params = ['content'];
$params = ['content', (string) $content_id];
if ($since !== null)
{
@@ -33,8 +74,10 @@ class Webservicelog_model extends DB_Model
$params[] = $since;
}
$query .= ' GROUP BY request_id';
$result = $this->execReadOnlyQuery($query, $params);
if (isError($result))
return $result;
return $this->execReadOnlyQuery($query, $params);
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(
+1
View File
@@ -29,6 +29,7 @@
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); ?>
+41
View File
@@ -54,3 +54,44 @@ body {
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;
}
+15 -6
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 } : {})
}
};
},
@@ -55,11 +57,16 @@ export default {
};
},
// Used by the CMS content component "oe-personen".
getOePersonen(oe_kurzbz, foto) {
// 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 } : {}) }
params: {
oe_kurzbz,
...(foto ? { foto: 1 } : {}),
...(tiefe ? { tiefe } : {})
}
};
},
// Used by the CMS content component "person-block".
@@ -79,11 +86,13 @@ export default {
};
},
// Used by the CMS content component "dms-liste".
getDmsKategorie(kategorie_kurzbz) {
// 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 }
params: { kategorie_kurzbz, ...(tiefe ? { tiefe } : {}) }
};
},
// Used by the CMS content component "contentchild-menu".
+46 -3
View File
@@ -30,11 +30,38 @@ export default {
params: { content_id, sprache, version }
};
},
getClickCounts(months) {
// Feeds the tree filter: child count, insertamum, updateamum and the own-edit flag.
getTreeMeta() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getClickCounts',
params: { months }
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() {
@@ -208,6 +235,14 @@ export default {
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',
@@ -249,5 +284,13 @@ export default {
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
@@ -137,7 +137,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
@@ -337,5 +338,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));
});
+1 -1
View File
@@ -13,7 +13,7 @@ const router = VueRouter.createRouter({
component: CmsAdmin,
children: [
{
// Only the content_id is required. CmsAdmin fills the missing parts
// 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?',
+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) {
+23 -12
View File
@@ -4,16 +4,17 @@ 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 Children from './Tabs/Children.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,
children: Children,
hierarchy: Hierarchy,
history: History,
delete: Delete
};
@@ -22,11 +23,15 @@ const TABS = [
{ key: 'properties', phrase: 'cms/tabEigenschaften' },
{ key: 'content', phrase: 'cms/tabInhalt' },
{ key: 'permissions', phrase: 'cms/tabRechte' },
{ key: 'children', phrase: 'cms/tabChilds' },
{ 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: {
@@ -38,7 +43,8 @@ export default {
props: {
cmsRoot: String,
authUid: String,
defaultLanguage: { type: String, default: 'German' }
defaultLanguage: { type: String, default: 'German' },
clickstats: { type: Boolean, default: false }
},
data() {
return {
@@ -51,10 +57,17 @@ export default {
};
},
computed: {
tabs() {
return this.clickstats ? TABS.concat([CLICKSTATS_TAB]) : TABS;
},
tabComponents() {
return this.clickstats
? { ...TAB_COMPONENTS, clickstats: Clickstats }
: TAB_COMPONENTS;
},
activeTabComponent() {
return TAB_COMPONENTS[this.tab] || null;
return this.tabComponents[this.tab] || null;
},
// Two switches in flight can resolve out of order, so block the view during one.
busy() {
return this.pending > 0;
}
@@ -65,7 +78,8 @@ export default {
const id = params.content_id ? Number(params.content_id) : null;
const sprache = params.sprache || null;
const version = params.version ? Number(params.version) : null;
const tab = params.tab || 'properties';
// 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) {
@@ -171,7 +185,7 @@ export default {
},
// Three grades of tree update. Pick the cheapest one that stays correct.
// A full reload drops the filter, the expanded branches and the click ranking,
// 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();
@@ -285,8 +299,5 @@ export default {
</div>
</div>
</div>
`,
created() {
this.tabs = TABS;
}
`
};
@@ -18,7 +18,12 @@ import {
const FELD_KATEGORIE = '__kategorie';
const FELD_DOKUMENT = '__dokument';
const LEERE_AUSWAHL = { value: '', text: '— auswählen —' };
// 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.
@@ -27,7 +32,8 @@ const SOURCES = {
request: () => ApiCmsAdmin.getOrganisationseinheiten(),
item: oe => ({
value: oe.oe_kurzbz,
text: (oe.bezeichnung || oe.oe_kurzbz) + (oe.aktiv ? '' : ' (inaktiv)')
text: (oe.bezeichnung || oe.oe_kurzbz)
+ (oe.aktiv ? '' : ' (' + t('inaktiv') + ')')
})
},
dmskategorie: {
@@ -100,6 +106,8 @@ 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 = {};
@@ -127,20 +135,23 @@ export function createContentcomponentDialog(options)
return;
editor.windowManager.open({
title: 'Contentcomponent einfügen',
title: t('ccDialogTitel'),
initialData: { component: catalog[0].name },
body: {
type: 'panel',
items: [{
type: 'selectbox',
name: 'component',
label: 'Contentcomponent',
items: catalog.map(entry => ({ value: entry.name, text: entry.label }))
label: t('ccContentcomponent'),
items: catalog.map(entry => ({
value: entry.name,
text: t(entry.labelPhrase)
}))
}]
},
buttons: [
{ type: 'cancel', text: 'Abbrechen' },
{ type: 'submit', text: 'Weiter', primary: true }
{ type: 'cancel', text: t('abbrechen') },
{ type: 'submit', text: t('weiter'), primary: true }
],
onSubmit(dialog) {
const name = dialog.getData().component;
@@ -183,7 +194,7 @@ export function createContentcomponentDialog(options)
const wert = values && values[propName];
if (wert && !liste.some(eintrag => eintrag.value === wert))
liste.unshift({ value: wert, text: wert + ' (nicht in der Auswahl)' });
liste.unshift({ value: wert, text: t('ccNichtInDerAuswahl', [wert]) });
return liste;
}
@@ -196,7 +207,7 @@ export function createContentcomponentDialog(options)
alle.push({
type: 'selectbox',
name: propName,
label: spec.label || propName,
label: spec.labelPhrase ? t(spec.labelPhrase) : propName,
items: listeFuer(propName, spec)
});
return alle;
@@ -207,7 +218,7 @@ export function createContentcomponentDialog(options)
alle.push({
type: 'checkbox',
name: propName,
label: spec.label || propName
label: spec.labelPhrase ? t(spec.labelPhrase) : propName
});
return alle;
}
@@ -215,7 +226,7 @@ export function createContentcomponentDialog(options)
alle.push({
type: 'input',
name: propName,
label: spec.label || propName
label: spec.labelPhrase ? t(spec.labelPhrase) : propName
});
if (spec.picker === 'dms')
@@ -223,14 +234,14 @@ export function createContentcomponentDialog(options)
alle.push({
type: 'selectbox',
name: propName + FELD_KATEGORIE,
label: 'Kategorie',
items: [LEERE_AUSWAHL].concat(sourceItems.dmskategorie || [])
label: t('ccKategorie'),
items: [leereAuswahl()].concat(sourceItems.dmskategorie || [])
});
alle.push({
type: 'selectbox',
name: propName + FELD_DOKUMENT,
label: 'Dokument hinzufügen',
items: [LEERE_AUSWAHL].concat(dokumente)
label: t('ccDokumentHinzufuegen'),
items: [leereAuswahl()].concat(dokumente)
});
}
@@ -241,15 +252,15 @@ export function createContentcomponentDialog(options)
function konfigBauen(daten)
{
return {
title: descriptor.label,
title: t(descriptor.labelPhrase),
initialData: daten,
body: { type: 'panel', items: itemsBauen() },
onChange(dialog, details) {
aufAenderung(dialog, details.name);
},
buttons: [
{ type: 'cancel', text: 'Abbrechen' },
{ type: 'submit', text: 'Übernehmen', primary: true }
{ type: 'cancel', text: t('abbrechen') },
{ type: 'submit', text: t('uebernehmen'), primary: true }
],
onSubmit(dialog) {
const html = markerHtml(name, descriptor, dialog.getData());
@@ -291,8 +302,9 @@ export function createContentcomponentDialog(options)
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.name
+ (dokument.cis_suche ? '' : ' (nicht in der CIS-Suche)')
text: dokument.cis_suche
? dokument.name
: t('ccNichtInCisSuche', [dokument.name])
}));
})
.catch(() => { dokumente = []; })
+20 -12
View File
@@ -65,6 +65,8 @@ export default {
// 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())
});
@@ -85,18 +87,24 @@ export default {
+ 'content:"Contentcomponent: " attr(' + CONTENTCOMPONENT_ATTR + ');'
+ 'font:12px monospace;color:#555;}',
forced_root_block: '',
style_formats: [
{ title: 'Contenttabelle', selector: 'table', classes: 'cmstable' },
{ title: 'Hauptcontent', block: 'div', classes: 'cmscontent' },
{ title: 'Menuebox', block: 'div', classes: 'menubox' },
{ title: 'Teambox', block: 'div', classes: 'teambox' },
{ title: 'Schatteneffekt Grafiken', selector: 'img', classes: 'screenshot_boxshadow' },
{ title: 'Tablesorter', selector: 'table',
classes: 'tablesorter tablesorter_col_0 tablesorter_sort_0' }
],
// 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 | styleselect fontsizeselect',
'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'
],
@@ -110,8 +118,8 @@ export default {
vm.editor = editor;
editor.ui.registry.addButton('contentcomponent', {
text: 'Contentcomponent',
tooltip: 'Contentcomponent einfügen',
text: vm.$p.t('cms/ccContentcomponent'),
tooltip: vm.$p.t('cms/ccDialogTitel'),
onAction: () => vm.contentcomponentDialog.openPicker(editor)
});
@@ -19,7 +19,9 @@ export default {
return {
titel: '',
allSprachen: [],
versionDetails: []
versionDetails: [],
// Target of the translation dropdown, used once the list grows past a pair.
neueSprache: ''
};
},
computed: {
@@ -104,6 +106,7 @@ export default {
createTranslation(newSprache) {
if (!newSprache) return;
this.neueSprache = '';
this.$api
.call(ApiCmsAdmin.postTranslation(
this.contentId, this.sprache, this.version, newSprache
@@ -149,10 +152,27 @@ export default {
>{{ $p.t('cms/neueVersionAnlegen') }}</button>
</div>
<div class="d-flex align-items-center gap-1">
<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>
<!-- 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>
@@ -5,8 +5,24 @@ export 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: `
<div class="btn-group btn-group-sm" role="group">
<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"
@@ -1,3 +1,6 @@
// Versions shown on each side of the open one.
const WINDOW = 3;
export default {
name: 'VersionSelect',
props: {
@@ -7,6 +10,38 @@ export default {
},
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) {
@@ -22,17 +57,25 @@ export default {
},
template: `
<div class="btn-group btn-group-sm" role="group">
<button
v-for="ver in versions"
:key="ver"
type="button"
class="btn"
:class="[
ver === version ? 'btn-primary' : 'btn-outline-secondary',
{ 'opacity-50': !isSichtbar(ver) }
]"
@click="$emit('select-version', ver)"
>{{ ver }}</button>
<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>
`
};
+253 -10
View File
@@ -1,3 +1,14 @@
// 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: {
@@ -5,8 +16,42 @@ export default {
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
@@ -27,6 +72,40 @@ export default {
+ 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() {
@@ -34,31 +113,195 @@ export default {
}
},
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">
<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>
<template v-if="isRedirect">
<a :href="previewUrl" target="_blank">
{{ $p.t('cms/vorschauInEigenemFenster') }}
</a>
<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>
<iframe
ref="previewFrame"
:src="previewUrl"
style="width: 100%; min-height: 500px; border: 1px solid #ccc;"
></iframe>
<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>
`
-183
View File
@@ -1,183 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsChildren',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'refresh-tree-node', 'select-content'],
data() {
return {
childs: [],
possibleChilds: [],
selectedChild: ''
};
},
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.loadChilds();
this.loadPossibleChilds();
},
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);
}
},
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);
}
},
onChildClick(child_content_id) {
this.$emit('select-content', child_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/folgendeEintraegeSindUntergeordnet') }}</h5>
<template v-if="childs.length > 0">
<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">
<td class="text-nowrap">
{{ child.sort }}
<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="#" @click.prevent="onChildClick(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>
`
};
+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>
`
};
+16 -7
View File
@@ -27,13 +27,19 @@ export default {
};
},
computed: {
// The preview below uses the legacy renderer, which runs no Vue. Tell the editor
// as soon as the content holds a marker, so an empty preview is not a surprise.
// 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';
@@ -170,11 +176,12 @@ export default {
</button>
</template>
<div v-if="hasContentcomponents" class="alert alert-info">
Dieser Inhalt enthält Contentcomponents. Die Vorschau unten zeigt sie nicht,
weil sie den alten Renderer ohne Vue verwendet. Prüfe sie über den Link
"In CIS4 ansehen".
</div>
<xsd-form
v-if="isRedirect && lockState !== 'own'"
:schema="schema"
:modelValue="values"
:disabled="true"
></xsd-form>
<cms-preview
ref="preview"
@@ -182,6 +189,8 @@ export default {
: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>
+11 -5
View File
@@ -19,7 +19,7 @@ export default {
hasRestrict() {
return this.usages.some(u => u.table !== 'campus.tbl_infoscreen_content');
},
// Request to T07: forward defaultLanguage prop to tabs. Using 'German' as fallback.
// forward defaultLanguage prop to tabs. Using 'German' as fallback.
isLastVersion() {
if (!this.contentInfo?.versions || !this.sprache) return false;
const defaultLang = 'German';
@@ -125,13 +125,19 @@ export default {
</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">
<span>{{ u.label }}</span>
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">
class="badge bg-danger text-nowrap">
{{ $p.t('cms/wirdMitgeloescht') }}
</span>
<span v-else class="badge bg-warning text-dark">
<span v-else class="badge bg-warning text-dark text-nowrap">
{{ $p.t('cms/verhindertLoeschen') }}
</span>
</li>
+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>
`
};
+243 -76
View File
@@ -1,7 +1,36 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import TreeFilter from './TreeFilter.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'],
@@ -10,11 +39,25 @@ const TreeNode = {
activeContentId: Number,
expandedKeys: Object,
flat: Boolean,
clicks: Object
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: {
clickCount() {
return this.clicks ? (this.clicks[this.node.content_id] || 0) : null;
// 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;
@@ -26,6 +69,10 @@ const TreeNode = {
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) {
@@ -41,7 +88,19 @@ const TreeNode = {
}
},
template: `
<li class="py-1">
<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)">
@@ -49,18 +108,18 @@ const TreeNode = {
:class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
</span>
<span v-else-if="!flat" style="display:inline-block;width:1.25em"></span>
<i v-if="node.groups && node.groups.length"
class="fa-solid fa-lock fa-sm me-1"
:title="node.groups.join(', ')"></i>
<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>
<span v-if="clickCount !== null"
<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="clickCount ? 'bg-secondary' : 'bg-light text-muted'">{{ clickCount }}</span>
: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"
@@ -69,9 +128,12 @@ const TreeNode = {
:active-content-id="activeContentId"
:expanded-keys="expandedKeys"
:flat="flat"
:clicks="clicks"
:meta="meta"
:metric="metric"
:muted="muted || indent > 0"
></cms-tree-node>
</ul>
</div>
</li>
`
};
@@ -95,18 +157,65 @@ export default {
nodes: [],
loading: false,
expandedKeys: {},
clicks: null,
clicksSince: null,
ranked: false,
clicksLoading: false,
months: 12,
loadedMonths: null
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: {
// Flat on purpose: a relic sits at any depth, so per-level sorting would hide it.
// 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.ranked || !this.clicks) return this.nodes;
if (!this.active) return this.nodes;
const flatList = [];
const walk = (nodes) => {
@@ -117,9 +226,7 @@ export default {
};
walk(this.nodes);
return flatList.slice().sort(
(a, b) => (this.clicks[b.content_id] || 0) - (this.clicks[a.content_id] || 0)
);
return this.sortNodes(flatList.filter(node => this.matches(node)));
}
},
provide() {
@@ -157,6 +264,9 @@ export default {
// --- 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();
},
@@ -232,40 +342,107 @@ export default {
this.$emit('select-content', node.content_id);
}
},
toggleRanking() {
if (this.ranked) {
this.ranked = false;
return;
}
this.loadClicks();
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;
},
// A wider period costs a longer query, so cache the counts per period.
loadClicks() {
if (this.clicks && this.loadedMonths === this.months) {
this.ranked = true;
return;
// 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 || '')
);
}
this.clicksLoading = true;
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.getClickCounts(this.months))
.then(result => {
this.clicks = result.data.counts || {};
this.clicksSince = result.data.since;
this.loadedMonths = result.data.months;
this.ranked = true;
})
.call(ApiCmsAdmin.getTreeMeta())
.then(result => { this.meta = result.data || {}; })
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.clicksLoading = false; });
.finally(() => { this.metaLoading = false; });
},
onMonthsChange() {
if (this.ranked || this.clicks) this.loadClicks();
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();
this.loadTree().then(() => { this.loadAncestors(); });
},
createEntry(parentId) {
this.$api
@@ -285,16 +462,16 @@ export default {
.catch(this.$fhcAlert.handleSystemError);
}
},
created() {
this.monthOptions = [3, 6, 12, 24, 0];
},
mounted() {
this.loadTree();
},
template: `
<div class="p-2">
<menu-switch v-model="menu"></menu-switch>
<tree-filter class="mt-2" @filter="onFilter"></tree-filter>
<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)">
@@ -306,37 +483,25 @@ export default {
{{ $p.t('cms/neuenChildEintragHinzufuegen') }}
</button>
</div>
<div class="mb-2">
<div class="input-group input-group-sm">
<button class="btn"
:class="ranked ? 'btn-secondary' : 'btn-outline-secondary'"
:disabled="clicksLoading"
@click="toggleRanking">
<i class="fa-solid fa-fw"
:class="clicksLoading ? 'fa-spinner fa-spin' : 'fa-arrow-down-9-1'"></i>
{{ ranked ? $p.t('cms/hierarchieZeigen') : $p.t('cms/nachKlicksReihen') }}
</button>
<select class="form-select"
v-model.number="months"
:disabled="clicksLoading"
:title="$p.t('cms/zeitraum')"
@change="onMonthsChange">
<option v-for="m in monthOptions" :key="m" :value="m">
{{ m ? $p.t('cms/letzteNMonate', [m]) : $p.t('cms/gesamterZeitraum') }}
</option>
</select>
</div>
<div v-if="loadedMonths !== null" class="form-text mt-1">
{{ clicksSince
? $p.t('cms/klicksSeit', [clicksSince.substring(0, 10)])
: $p.t('cms/klicksGesamt') }}
<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 ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }}
{{ (filter || active) ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }}
</div>
<ul v-else class="list-unstyled mb-0">
<cms-tree-node
@@ -345,8 +510,10 @@ export default {
:node="node"
:active-content-id="activeContentId"
:expanded-keys="expandedKeys"
:flat="ranked || menu === 'news'"
:clicks="clicks"
:flat="isFlat && !showPaths"
:meta="meta"
:metric="metric"
:ancestors="pathFor(node.content_id)"
></cms-tree-node>
</ul>
</div>
+193 -10
View File
@@ -1,26 +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',
emits: ['filter'],
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: ''
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 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>
<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>
`
};
@@ -68,7 +68,7 @@ export default {
<div class="fhc-contentcomponent-contentchildmenu">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Das Menü konnte nicht geladen werden.
{{ $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. -->
@@ -60,6 +60,10 @@ export default {
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() {
@@ -43,7 +43,7 @@ export default {
<div class="fhc-contentcomponent-dmsdokumente">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Die Dokumentenliste konnte nicht geladen werden.
{{ $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. -->
@@ -17,6 +17,12 @@ export default {
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() {
@@ -32,7 +38,7 @@ export default {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getDmsKategorie(this.kategorieKurzbz))
.call(ApiCms.getDmsKategorie(this.kategorieKurzbz, this.tiefe))
.then(res => {
this.zugriff = res.data.zugriff;
this.dokumente = res.data.dokumente || [];
@@ -42,7 +48,8 @@ export default {
}
},
watch: {
kategorieKurzbz() { this.load(); }
kategorieKurzbz() { this.load(); },
tiefe() { this.load(); }
},
created() {
this.load();
@@ -51,13 +58,13 @@ export default {
<div class="fhc-contentcomponent-dmsliste">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Die Dokumentenliste konnte nicht geladen werden.
{{ $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">
Keine Dokumente vorhanden.
{{ $p.t('cms/ccKeineDokumente') }}
</div>
<dokument-liste v-else :dokumente="dokumente"></dokument-liste>
</template>
@@ -24,6 +24,12 @@ export default {
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() {
@@ -38,7 +44,7 @@ export default {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getOePersonen(this.oeKurzbz, this.foto))
.call(ApiCms.getOePersonen(this.oeKurzbz, this.foto, this.tiefe))
.then(res => { this.personen = res.data || []; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
@@ -46,7 +52,8 @@ export default {
},
watch: {
oeKurzbz() { this.load(); },
foto() { this.load(); }
foto() { this.load(); },
tiefe() { this.load(); }
},
created() {
this.load();
@@ -55,10 +62,10 @@ export default {
<div class="fhc-contentcomponent-oepersonen">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Die Personen konnten nicht geladen werden.
{{ $p.t('cms/ccLadefehlerPersonen') }}
</div>
<div v-else-if="!personen.length" class="text-muted">
Keine Personen zugeordnet.
{{ $p.t('cms/ccKeinePersonen') }}
</div>
<template v-else>
<person-block
@@ -60,7 +60,7 @@ export default {
<div class="fhc-contentcomponent-personkontakt">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Die Person konnte nicht geladen werden.
{{ $p.t('cms/ccLadefehlerPerson') }}
</div>
<person-block
v-else-if="person"
@@ -49,10 +49,10 @@ and `DokumentListe.js`.
```js
{
name: 'mein-baustein', // data-fhc-component, and the key in components.js
label: 'Mein Baustein', // shown in the picker
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, label: 'Mein Wert' }
'mein-wert': { type: 'string', required: true, labelPhrase: 'ccMeinWert' }
}
}
```
@@ -27,9 +27,9 @@ export const CONTENTCOMPONENT_MAX = 20;
/**
* One entry per available component.
*
* name value of data-fhc-component, and the key in components.js
* label shown in the insert dialog. TODO(phrases): move to category cms.
* props attribute name (kebab-case) => { type, required, label, source }
* 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.
@@ -40,51 +40,56 @@ export const CONTENTCOMPONENT_MAX = 20;
export const catalog = [
{
name: 'oe-personen',
label: 'Team einer Organisationseinheit (automatisch)',
labelPhrase: 'ccOePersonen',
props: {
'oe-kurzbz': {
type: 'string',
required: true,
label: 'Organisationseinheit',
labelPhrase: 'ccOrganisationseinheit',
source: 'oe'
},
'foto': {
type: 'boolean',
required: false,
label: 'Fotos anzeigen'
labelPhrase: 'ccFotosAnzeigen'
},
'tiefe': {
type: 'number',
required: false,
labelPhrase: 'ccTiefeOe'
}
}
},
{
name: 'person-block',
label: 'Einzelne Person (Kontaktblock)',
labelPhrase: 'ccPersonBlock',
props: {
'uid': {
type: 'string',
required: true,
label: 'Person',
labelPhrase: 'ccPerson',
source: 'person'
},
'funktion': {
type: 'string',
required: false,
label: 'Funktionsbezeichnung (leer = aus den Personaldaten)'
labelPhrase: 'ccFunktionsbezeichnung'
},
'foto': {
type: 'boolean',
required: false,
label: 'Foto anzeigen'
labelPhrase: 'ccFotoAnzeigen'
}
}
},
{
name: 'dms-dokumente',
label: 'Dokumentenliste (einzelne Dokumente)',
labelPhrase: 'ccDmsDokumente',
props: {
'dms-ids': {
type: 'string',
required: true,
label: 'DMS-IDs, mit Komma getrennt',
labelPhrase: 'ccDmsIds',
// Adds a category filter and a document list to the dialog. Choosing a
// document appends its id to this field.
picker: 'dms'
@@ -93,24 +98,29 @@ export const catalog = [
},
{
name: 'dms-liste',
label: 'Dokumentenliste (ganze DMS-Kategorie)',
labelPhrase: 'ccDmsListe',
props: {
'kategorie-kurzbz': {
type: 'string',
required: true,
label: 'DMS-Kategorie',
labelPhrase: 'ccDmsKategorie',
source: 'dmskategorie'
},
'tiefe': {
type: 'number',
required: false,
labelPhrase: 'ccTiefeDms'
}
}
},
{
name: 'contentchild-menu',
label: 'Menü der Kindelemente',
labelPhrase: 'ccContentchildMenu',
props: {
'content-id': {
type: 'number',
required: false,
label: 'Content-ID (leer = diese Seite)'
labelPhrase: 'ccContentId'
}
}
}
File diff suppressed because it is too large Load Diff