mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-10-12 06:54:04 +00:00
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:
@@ -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,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')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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); ?>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = []; })
|
||||
|
||||
@@ -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])"
|
||||
>…</button>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user