mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-10-12 15:04:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2af21124e | ||
|
|
4ba2869632 | ||
|
|
293bb77179 | ||
|
|
3fe10d715d | ||
|
|
36064afbba | ||
|
|
f3d031ab16 | ||
|
|
3d8c4fa4d0 | ||
|
|
272014bd78 | ||
|
|
76a8e00edf | ||
|
|
c8c7d1566c | ||
|
|
9d4218bf38 | ||
|
|
67f30c7765 | ||
|
|
d40040efd8 |
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
// Preferred organisational unit for a new content.
|
||||
// If the user is entitled for this unit, it wins.
|
||||
// If not, the first entitled unit wins.
|
||||
$config['default_oe_kurzbz'] = 'etw';
|
||||
|
||||
// Lifetime of an edit lock in hours, counted from tbl_contentlog.start. Must be > 0.
|
||||
$config['lock_ttl_hours'] = 24;
|
||||
|
||||
// Switches the click statistics off completely. With false the admin tab stays hidden and
|
||||
// the endpoint refuses every request, whatever the rights of the caller are.
|
||||
$config['clickstats_enabled'] = true;
|
||||
|
||||
// Default period of the click statistics in months, when the caller sends none.
|
||||
$config['clickstats_months'] = 12;
|
||||
|
||||
// Length of the ranking in the click statistics tab.
|
||||
$config['clickstats_limit'] = 100;
|
||||
@@ -75,10 +75,6 @@ $route['Abgabetool/Student'] = 'Cis/Abgabetool/Student';
|
||||
$route['Abgabetool/Student/(:any)'] = 'Cis/Abgabetool/Student/$1';
|
||||
$route['Abgabetool/Deadlines'] = 'Cis/Abgabetool/Deadlines';
|
||||
|
||||
// CMS Admin App Routes
|
||||
$route['cms'] = 'cms/index';
|
||||
$route['cms/.*'] = 'cms/index';
|
||||
|
||||
// Studierendenverwaltung App Routes
|
||||
$route['studvw'] = 'studentenverwaltung/index';
|
||||
$route['studvw/.*'] = 'studentenverwaltung/index';
|
||||
|
||||
@@ -44,22 +44,19 @@ class Cms extends Auth_Controller
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function content($content_id = null, $version = null, $sprache = null, $sichtbar = true)
|
||||
public function content($content_id, $version = null, $sprache = null, $sichtbar = true)
|
||||
{
|
||||
if (!is_numeric($content_id))
|
||||
show_404();
|
||||
// return early if the content_id for the content is missing
|
||||
if (!isset($content_id))
|
||||
$this->terminateWithError("content_id is missing");
|
||||
|
||||
$content = $this->ContentModel->load($content_id);
|
||||
if (isError($content))
|
||||
{
|
||||
show_error('The content could not be loaded');
|
||||
}
|
||||
$this->terminateWithError(getError($content));
|
||||
|
||||
$content = getData($content);
|
||||
|
||||
if (NULL === $content) {
|
||||
show_404();
|
||||
}
|
||||
if (NULL === $content)
|
||||
$this->terminateWithError("Content not found");
|
||||
|
||||
$content = current($content);
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
class Cms extends Auth_Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(['index' => 'basis/cms:r']);
|
||||
$this->load->library('PermissionLib');
|
||||
$this->config->load('cms');
|
||||
$this->loadPhrases(['global', 'ui', 'cms']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
// decide whether the click statistics tab is shown at all.
|
||||
$this->load->view('Cms.php', [
|
||||
'clickstats' => $this->config->item('clickstats_enabled')
|
||||
&& $this->permissionlib->isBerechtigt('admin')
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,6 @@ 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
|
||||
@@ -43,11 +39,6 @@ class Cms extends FHCAPI_Controller
|
||||
'news' => self::PERM_LOGGED,
|
||||
'getNewsRowCount' => self::PERM_LOGGED,
|
||||
'getNews' => self::PERM_LOGGED,
|
||||
'getOePersonen' => self::PERM_LOGGED,
|
||||
'getDmsKategorie' => self::PERM_LOGGED,
|
||||
'getContentChilds' => self::PERM_LOGGED,
|
||||
'getPerson' => self::PERM_LOGGED,
|
||||
'getDmsDokumente' => self::PERM_LOGGED,
|
||||
|
||||
]);
|
||||
|
||||
@@ -64,7 +55,11 @@ class Cms extends FHCAPI_Controller
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Private methods
|
||||
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Public methods
|
||||
|
||||
@@ -85,10 +80,7 @@ class Cms extends FHCAPI_Controller
|
||||
$sprache = $this->input->get("sprache",TRUE);
|
||||
$sichtbar = $this->input->get("sichtbar",TRUE);
|
||||
|
||||
// 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->cmslib->getContent($content_id, $version, $sprache, $sichtbar);
|
||||
$content = $this->getDataOrTerminateWithError($content);
|
||||
|
||||
$this->terminateWithSuccess($content);
|
||||
@@ -211,331 +203,5 @@ class Cms extends FHCAPI_Controller
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Lists the people assigned to an organisation unit.
|
||||
* Used by the CMS content component "oe-personen".
|
||||
*/
|
||||
public function getOePersonen()
|
||||
{
|
||||
// form validation
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('oe_kurzbz', 'Organisationseinheit', 'required');
|
||||
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
|
||||
$this->load->model('organisation/Organisationseinheit_model', 'OrganisationseinheitModel');
|
||||
|
||||
$oe_kurzbz = $this->input->get('oe_kurzbz', TRUE);
|
||||
$mitFoto = $this->input->get('foto', TRUE) ? true : false;
|
||||
|
||||
// tiefe 0 asks about the one unit. Deeper takes the sub units with it.
|
||||
$oeResult = $this->OrganisationseinheitModel->getMitUntereinheiten(
|
||||
$oe_kurzbz, $this->_tiefe()
|
||||
);
|
||||
$oeRows = $this->getDataOrTerminateWithError($oeResult) ?: array();
|
||||
|
||||
$uids = array();
|
||||
$funktionen = array();
|
||||
|
||||
foreach ($oeRows as $oe)
|
||||
{
|
||||
// 'oezuordnung' is the function that assigns an employee to an organisation
|
||||
// unit. The model already filters datum_von and datum_bis.
|
||||
$rows = $this->BenutzerfunktionModel->getBenutzerFunktionenDetailed(
|
||||
'oezuordnung', $oe->oe_kurzbz
|
||||
);
|
||||
$rows = $this->getDataOrTerminateWithError($rows) ?: array();
|
||||
|
||||
foreach ($rows as $row)
|
||||
{
|
||||
// The model returns the aktiv flag but does not filter on it.
|
||||
if (!$row->aktiv)
|
||||
continue;
|
||||
|
||||
// One person can hold the assignment in several units of the walk. The
|
||||
// first one wins, so the list keeps one block per person.
|
||||
if (isset($funktionen[$row->uid]))
|
||||
continue;
|
||||
|
||||
$uids[] = $row->uid;
|
||||
// tbl_benutzerfunktion.bezeichnung is the free label of one assignment,
|
||||
// for example "Bibliothekarin". tbl_funktion.beschreibung is the generic
|
||||
// name.
|
||||
$funktionen[$row->uid] = $row->bezeichnung ?: $row->beschreibung;
|
||||
}
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess($this->_cmsPersonen($uids, $funktionen, $mitFoto));
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact data of one person.
|
||||
* Used by the CMS content component "person-block".
|
||||
*/
|
||||
public function getPerson()
|
||||
{
|
||||
// form validation
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('uid', 'UID', 'required');
|
||||
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$uid = $this->input->get('uid', TRUE);
|
||||
$mitFoto = $this->input->get('foto', TRUE) ? true : false;
|
||||
|
||||
// The function label comes from the marker, not from the database. The component
|
||||
// passes it through as a property.
|
||||
$personen = $this->_cmsPersonen(array($uid), array(), $mitFoto);
|
||||
|
||||
$this->terminateWithSuccess($personen ? current($personen) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the published documents of one DMS category.
|
||||
* Used by the CMS content component "dms-liste".
|
||||
*/
|
||||
public function getDmsKategorie()
|
||||
{
|
||||
// form validation
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required');
|
||||
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$kategorie_kurzbz = $this->input->get('kategorie_kurzbz', TRUE);
|
||||
|
||||
$erlaubt = $this->_dmsKategorieErlaubt($kategorie_kurzbz);
|
||||
|
||||
if ($erlaubt === null)
|
||||
$this->terminateWithError('DMS category not found', self::ERROR_TYPE_404);
|
||||
|
||||
// a missing entitlement answers with success and an empty list, not with
|
||||
// an error. The component sits inside a public content page. An error would raise a
|
||||
// toast for every reader who is simply not in the group.
|
||||
$dokumente = array();
|
||||
if ($erlaubt)
|
||||
{
|
||||
// tiefe 0 asks about the one category. Deeper takes the sub categories with it.
|
||||
$katResult = $this->DmsModel->getMitUnterkategorien(
|
||||
$kategorie_kurzbz, $this->_tiefe()
|
||||
);
|
||||
$kategorien = $this->getDataOrTerminateWithError($katResult) ?: array();
|
||||
|
||||
foreach ($kategorien as $kat)
|
||||
{
|
||||
// Every category carries its own group rule, so a sub category can be
|
||||
// closed while the parent is open. Ask for each one instead of letting the
|
||||
// entitlement of the root stand for the whole walk. The root is already
|
||||
// answered above.
|
||||
if ($kat->kategorie_kurzbz !== $kategorie_kurzbz
|
||||
&& $this->_dmsKategorieErlaubt($kat->kategorie_kurzbz) !== true)
|
||||
continue;
|
||||
|
||||
$rows = $this->DmsModel->getKategorieDokumente($kat->kategorie_kurzbz);
|
||||
$rows = $this->getDataOrTerminateWithError($rows) ?: array();
|
||||
|
||||
foreach ($rows as $row)
|
||||
$dokumente[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess(array(
|
||||
'zugriff' => $erlaubt,
|
||||
'dokumente' => $dokumente
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists named documents, in the order the caller asked for them.
|
||||
* Used by the CMS content component "dms-dokumente".
|
||||
*/
|
||||
public function getDmsDokumente()
|
||||
{
|
||||
// form validation
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('dms_ids', 'DMS-IDs', 'required');
|
||||
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$this->load->model('content/Dms_model', 'DmsModel');
|
||||
|
||||
$ids = array();
|
||||
foreach (explode(',', $this->input->get('dms_ids', TRUE)) as $teil)
|
||||
{
|
||||
$teil = trim($teil);
|
||||
if (is_numeric($teil))
|
||||
$ids[] = (int) $teil;
|
||||
}
|
||||
|
||||
// An unbounded list would let one marker fetch the whole DMS.
|
||||
$ids = array_slice(array_unique($ids), 0, 50);
|
||||
|
||||
$dokumente = $this->DmsModel->getDokumenteByIds($ids);
|
||||
$dokumente = $this->getDataOrTerminateWithError($dokumente) ?: array();
|
||||
|
||||
// A hand picked list holds few categories, so deciding per category is cheap and
|
||||
// reuses the rule of getDmsKategorie().
|
||||
$erlaubte = array();
|
||||
$nachId = array();
|
||||
foreach ($dokumente as $dokument)
|
||||
{
|
||||
if (!array_key_exists($dokument->kategorie_kurzbz, $erlaubte))
|
||||
$erlaubte[$dokument->kategorie_kurzbz] = $this->_dmsKategorieErlaubt($dokument->kategorie_kurzbz);
|
||||
|
||||
if ($erlaubte[$dokument->kategorie_kurzbz] === true)
|
||||
$nachId[$dokument->dms_id] = $dokument;
|
||||
}
|
||||
|
||||
// The editor chose the order. Keep it.
|
||||
$sortiert = array();
|
||||
foreach ($ids as $id)
|
||||
{
|
||||
if (isset($nachId[$id]))
|
||||
$sortiert[] = $nachId[$id];
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess($sortiert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the child pages of a content, as a reader may see them.
|
||||
* Used by the CMS content component "contentchild-menu".
|
||||
*/
|
||||
public function getContentChilds()
|
||||
{
|
||||
// form validation
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural_no_zero');
|
||||
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$this->load->model('content/Contentchild_model', 'ContentchildModel');
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
if (!$sprache)
|
||||
$sprache = getUserLanguage();
|
||||
|
||||
$childs = $this->ContentchildModel->getChildsForReader($content_id, $sprache, getAuthUID());
|
||||
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($childs) ?: array());
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------------------------------------------
|
||||
// Private methods
|
||||
|
||||
/**
|
||||
* Builds the shape both person content components render.
|
||||
*
|
||||
* The assembly follows Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the
|
||||
* same in the news extras and on a content page.
|
||||
*
|
||||
* @param array $uids in the order the result should keep
|
||||
* @param array $funktionen uid => function label, may be empty
|
||||
* @param boolean $mitFoto
|
||||
* @return array
|
||||
*/
|
||||
private function _cmsPersonen($uids, $funktionen, $mitFoto)
|
||||
{
|
||||
if (empty($uids))
|
||||
return array();
|
||||
|
||||
$this->load->model('person/Person_model', 'PersonModel');
|
||||
|
||||
$kontakt = $this->PersonModel->getCmsKontakt($uids, $mitFoto);
|
||||
$kontakt = $this->getDataOrTerminateWithError($kontakt) ?: array();
|
||||
|
||||
$nachUid = array();
|
||||
foreach ($kontakt as $row)
|
||||
$nachUid[$row->uid] = $row;
|
||||
|
||||
$personen = array();
|
||||
|
||||
// Loops over $uids, not over the result, so the caller keeps its order.
|
||||
foreach ($uids as $uid)
|
||||
{
|
||||
if (!isset($nachUid[$uid]))
|
||||
continue;
|
||||
|
||||
$row = $nachUid[$uid];
|
||||
|
||||
$name = trim(implode(' ', array_filter(array(
|
||||
$row->titelpre, $row->vorname, $row->nachname, $row->titelpost
|
||||
))));
|
||||
|
||||
$telefon = '';
|
||||
if ($row->telefonklappe)
|
||||
$telefon = trim(($row->kontakt ?: '') . ' - ' . $row->telefonklappe);
|
||||
|
||||
$email = '';
|
||||
if (defined('DOMAIN'))
|
||||
$email = ($row->alias ?: $row->uid) . '@' . DOMAIN;
|
||||
|
||||
$personen[] = array(
|
||||
'uid' => $row->uid,
|
||||
'name' => $name,
|
||||
'funktion' => isset($funktionen[$uid]) ? $funktionen[$uid] : '',
|
||||
'telefon' => $telefon,
|
||||
'email' => $email,
|
||||
'ort' => $row->planbezeichnung,
|
||||
'foto' => $row->foto
|
||||
);
|
||||
}
|
||||
|
||||
return $personen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether the current user may see a DMS category.
|
||||
*
|
||||
* Same rule as cms/dms.php: an unlocked category is open to everyone. A locked one
|
||||
* needs a granted group, or the category permission together with basis/dms.
|
||||
*
|
||||
* @param string $kategorie_kurzbz
|
||||
* @return boolean|null null when the category does not exist
|
||||
*/
|
||||
/**
|
||||
* Reads the tiefe parameter of a content component. 0 keeps the component to the unit
|
||||
* or category it names, which is what a marker without the attribute means.
|
||||
* Capped, because the depth drives a recursive walk and one query per level found.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function _tiefe()
|
||||
{
|
||||
$tiefe = (int) $this->input->get('tiefe', TRUE);
|
||||
|
||||
if ($tiefe < 0)
|
||||
return 0;
|
||||
|
||||
return min($tiefe, self::MAX_TIEFE);
|
||||
}
|
||||
|
||||
private function _dmsKategorieErlaubt($kategorie_kurzbz)
|
||||
{
|
||||
$this->load->library('PermissionLib');
|
||||
$this->load->model('content/Dms_model', 'DmsModel');
|
||||
|
||||
$zugriff = $this->DmsModel->getKategorieZugriff($kategorie_kurzbz, getAuthUID());
|
||||
$zugriff = $this->getDataOrTerminateWithError($zugriff);
|
||||
|
||||
if (empty($zugriff))
|
||||
return null;
|
||||
|
||||
$kategorie = current($zugriff);
|
||||
|
||||
if (!$kategorie->gesperrt || $kategorie->in_gruppe)
|
||||
return true;
|
||||
|
||||
$erlaubt = $this->permissionlib->isBerechtigt('basis/dms');
|
||||
|
||||
if ($erlaubt && $kategorie->berechtigung_kurzbz != '')
|
||||
$erlaubt = $this->permissionlib->isBerechtigt($kategorie->berechtigung_kurzbz);
|
||||
|
||||
return $erlaubt;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,760 +0,0 @@
|
||||
<?php
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
class CmsAdmin extends FHCAPI_Controller
|
||||
{
|
||||
const MAX_TREE_DEPTH = 50;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct([
|
||||
'getTree' => ['basis/cms:r'],
|
||||
'getSubtree' => ['basis/cms:r'],
|
||||
'getContent' => ['basis/cms:r'],
|
||||
'getContentsprache' => ['basis/cms:r'],
|
||||
'getTemplates' => ['basis/cms:r'],
|
||||
'getOrganisationseinheiten' => ['basis/cms:r'],
|
||||
'getSprachen' => ['basis/cms:r'],
|
||||
'getDmsKategorien' => ['basis/cms:r'],
|
||||
'getMitarbeiter' => ['basis/cms:r'],
|
||||
'getDmsKategorieDokumente' => ['basis/cms:r'],
|
||||
'getUsage' => ['basis/cms:r'],
|
||||
'getTreeMeta' => ['basis/cms:r'],
|
||||
'getAncestors' => ['basis/cms:r'],
|
||||
'getClickCount' => ['basis/cms:r'],
|
||||
'getClickRanking' => ['basis/cms:r'],
|
||||
'postContent' => ['basis/cms:rw'],
|
||||
'postTranslation' => ['basis/cms:rw'],
|
||||
'postVersion' => ['basis/cms:rw'],
|
||||
'putProperties' => ['basis/cms:rw'],
|
||||
'deleteContent' => ['basis/cms:rw'],
|
||||
'deleteContentsprache' => ['basis/cms:rw']
|
||||
]);
|
||||
|
||||
$this->load->library('CmsAdminLib');
|
||||
$this->load->library('PermissionLib');
|
||||
$this->load->model('content/Content_model', 'ContentModel');
|
||||
$this->load->model('content/Contentsprache_model', 'ContentspracheModel');
|
||||
$this->load->model('content/Contentchild_model', 'ContentchildModel');
|
||||
$this->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
|
||||
$this->load->model('content/Template_model', 'TemplateModel');
|
||||
|
||||
$this->loadPhrases(['global', 'cms']);
|
||||
}
|
||||
|
||||
public function getTree()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('menu', 'Menu', 'required|in_list[content,news]');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$menu = $this->input->get('menu', TRUE);
|
||||
$filter = $this->input->get('filter', TRUE);
|
||||
|
||||
$entitledResult = $this->cmsadminlib->getEntitledOe();
|
||||
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? [];
|
||||
|
||||
$nodes = [];
|
||||
|
||||
if ($menu === 'news')
|
||||
{
|
||||
$newsResult = $this->ContentModel->getNewsContent();
|
||||
if (!isError($newsResult) && getData($newsResult))
|
||||
{
|
||||
foreach (getData($newsResult) as $row)
|
||||
{
|
||||
$titel = '';
|
||||
$csResult = $this->ContentspracheModel->getOne(
|
||||
(int) $row->content_id, DEFAULT_LANGUAGE
|
||||
);
|
||||
if (!isError($csResult))
|
||||
$titel = getData($csResult)->titel;
|
||||
|
||||
$node = new stdClass();
|
||||
$node->content_id = (int) $row->content_id;
|
||||
$node->titel = mb_substr($titel, 0, 15) . ' '
|
||||
. date('d.m.Y', strtotime($row->insertamum));
|
||||
$node->template_kurzbz = $row->template_kurzbz;
|
||||
$node->oe_kurzbz = $row->oe_kurzbz;
|
||||
$node->aktiv = ($row->aktiv === 't' || $row->aktiv === true);
|
||||
$node->entitled = false;
|
||||
$node->groups = [];
|
||||
$node->children = [];
|
||||
$nodes[] = $node;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!empty($filter))
|
||||
{
|
||||
$clean = str_replace(['!', '.', '?', ','], '', $filter);
|
||||
$searchItems = preg_split('/\s+/', $clean, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$rootsResult = $this->ContentModel->searchCms($searchItems);
|
||||
}
|
||||
else
|
||||
{
|
||||
$rootsResult = $this->ContentModel->getRootContent();
|
||||
}
|
||||
|
||||
if (!isError($rootsResult) && getData($rootsResult))
|
||||
{
|
||||
$visited = [];
|
||||
foreach (getData($rootsResult) as $row)
|
||||
{
|
||||
$node = $this->buildTreeNode(
|
||||
(int) $row->content_id, 0, $visited
|
||||
);
|
||||
if ($node !== null)
|
||||
$nodes[] = $node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allIds = [];
|
||||
$this->collectContentIds($nodes, $allIds);
|
||||
|
||||
$groupMap = [];
|
||||
if (!empty($allIds))
|
||||
{
|
||||
$gResult = $this->ContentgruppeModel->getGruppenForContents($allIds);
|
||||
if (!isError($gResult))
|
||||
$groupMap = getData($gResult);
|
||||
}
|
||||
|
||||
$this->applyMeta($nodes, $entitledOe, $groupMap);
|
||||
|
||||
$result = [];
|
||||
foreach ($nodes as $node)
|
||||
{
|
||||
$pruned = $this->pruneUnentitled($node);
|
||||
if ($pruned !== null)
|
||||
$result[] = $pruned;
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess($result);
|
||||
}
|
||||
|
||||
// One subtree in the shape of a getTree root node. Lets the client replace a branch
|
||||
// instead of rebuilding the whole tree after a change.
|
||||
// The visited map starts empty here, so a node with several parents can show up twice
|
||||
// until the next full reload. Rebuilding the whole tree on every change costs more.
|
||||
public function getSubtree()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = (int) $this->input->get('content_id', TRUE);
|
||||
|
||||
$entitledResult = $this->cmsadminlib->getEntitledOe();
|
||||
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult);
|
||||
|
||||
$visited = [];
|
||||
$node = $this->buildTreeNode($content_id, 0, $visited);
|
||||
|
||||
// Deleted meanwhile. The client drops the branch.
|
||||
if ($node === null)
|
||||
$this->terminateWithSuccess(null);
|
||||
|
||||
$nodes = [$node];
|
||||
|
||||
$allIds = [];
|
||||
$this->collectContentIds($nodes, $allIds);
|
||||
|
||||
$groupMap = [];
|
||||
if (!empty($allIds))
|
||||
{
|
||||
$gResult = $this->ContentgruppeModel->getGruppenForContents($allIds);
|
||||
if (!isError($gResult))
|
||||
$groupMap = getData($gResult);
|
||||
}
|
||||
|
||||
$this->applyMeta($nodes, $entitledOe, $groupMap);
|
||||
|
||||
$this->terminateWithSuccess($this->pruneUnentitled($node));
|
||||
}
|
||||
|
||||
public function getContent()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
|
||||
$entitledResult = $this->cmsadminlib->isEntitledForContent($content_id);
|
||||
$entitled = $this->getDataOrTerminateWithError($entitledResult);
|
||||
|
||||
if (!$entitled)
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDiesenEintrag'));
|
||||
|
||||
$this->ContentModel->resetQuery();
|
||||
$contentResult = $this->ContentModel->load($content_id);
|
||||
$contentData = $this->getDataOrTerminateWithError($contentResult);
|
||||
if (empty($contentData))
|
||||
$this->terminateWithError($this->p->t('cms', 'contentNichtGefunden'));
|
||||
$content = $contentData[0];
|
||||
|
||||
$langResult = $this->ContentspracheModel->getLanguages($content_id);
|
||||
$languages = $this->getDataOrTerminateWithError($langResult);
|
||||
|
||||
$versions = new stdClass();
|
||||
foreach ($languages as $sprache)
|
||||
{
|
||||
$vResult = $this->ContentspracheModel->getVersions($content_id, $sprache);
|
||||
if (!isError($vResult) && getData($vResult))
|
||||
{
|
||||
$versions->$sprache = array_map(function ($v) {
|
||||
return (int) $v->version;
|
||||
}, getData($vResult));
|
||||
}
|
||||
}
|
||||
|
||||
$result = new stdClass();
|
||||
$result->content_id = (int) $content->content_id;
|
||||
$result->template_kurzbz = $content->template_kurzbz;
|
||||
$result->oe_kurzbz = $content->oe_kurzbz;
|
||||
$result->aktiv = ($content->aktiv === 't' || $content->aktiv === true);
|
||||
$result->menu_open = ($content->menu_open === 't' || $content->menu_open === true);
|
||||
$result->beschreibung = $content->beschreibung ?: '';
|
||||
$result->entitled = true;
|
||||
$result->languages = $languages;
|
||||
$result->versions = $versions;
|
||||
|
||||
// Tells the CIS4 preview whether this template renders there at all.
|
||||
$cis4Result = $this->TemplateModel->hasCis4Stylesheet($content->template_kurzbz);
|
||||
$result->has_cis4_stylesheet = !isError($cis4Result) && getData($cis4Result);
|
||||
|
||||
$this->terminateWithSuccess($result);
|
||||
}
|
||||
|
||||
public function getContentsprache()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
$version = $this->input->get('version', TRUE);
|
||||
|
||||
$result = $this->ContentspracheModel->getOne($content_id, $sprache, $version);
|
||||
if (isError($result))
|
||||
$this->terminateWithError($this->p->t('cms', 'versionNichtGefunden'));
|
||||
|
||||
$data = getData($result);
|
||||
$data->sichtbar = ($data->sichtbar === 't' || $data->sichtbar === true);
|
||||
|
||||
$this->terminateWithSuccess($data);
|
||||
}
|
||||
|
||||
// The tree lists a search hit out of its place, so a generic title carries no context.
|
||||
// This answers where each one sits. One walk for the whole list, not one per node.
|
||||
const MAX_ANCESTOR_TIEFE = 12;
|
||||
|
||||
/**
|
||||
* Ancestors of the given contents, keyed by content_id and ordered from the top down.
|
||||
*/
|
||||
public function getAncestors()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_ids', 'Content IDs', 'required');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$ids = [];
|
||||
foreach (explode(',', $this->input->get('content_ids', TRUE)) as $teil)
|
||||
{
|
||||
$teil = trim($teil);
|
||||
if (is_numeric($teil))
|
||||
$ids[] = (int) $teil;
|
||||
}
|
||||
$ids = array_values(array_unique($ids));
|
||||
|
||||
if (empty($ids))
|
||||
$this->terminateWithSuccess(new stdClass());
|
||||
|
||||
$result = $this->ContentchildModel->getAncestors(
|
||||
$ids, self::MAX_ANCESTOR_TIEFE, DEFAULT_LANGUAGE
|
||||
);
|
||||
$rows = $this->getDataOrTerminateWithError($result);
|
||||
|
||||
// Same rule as the tree: an entry of an organisational unit the editor is not
|
||||
// entitled for stays readable but cannot be opened.
|
||||
$entitledResult = $this->cmsadminlib->getEntitledOe();
|
||||
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? [];
|
||||
|
||||
// The query orders by the distance, farthest first, so appending keeps the path
|
||||
// reading from the top down.
|
||||
$pfade = [];
|
||||
foreach ((array) $rows as $row)
|
||||
{
|
||||
$start = (int) $row->start_id;
|
||||
|
||||
if (!isset($pfade[$start]))
|
||||
$pfade[$start] = [];
|
||||
|
||||
$pfade[$start][] = [
|
||||
'content_id' => (int) $row->content_id,
|
||||
'titel' => $row->titel,
|
||||
'tiefe' => (int) $row->tiefe,
|
||||
'entitled' => in_array($row->oe_kurzbz, $entitledOe)
|
||||
];
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess($pfade ?: new stdClass());
|
||||
}
|
||||
|
||||
/**
|
||||
* Per content metadata for the tree filter, keyed by content_id.
|
||||
* One query for the whole tree, so the client filters and sorts without a round trip.
|
||||
*/
|
||||
public function getTreeMeta()
|
||||
{
|
||||
$result = $this->ContentModel->getTreeMeta(getAuthUID(), DEFAULT_LANGUAGE);
|
||||
$rows = $this->getDataOrTerminateWithError($result);
|
||||
|
||||
$meta = [];
|
||||
foreach ((array) $rows as $row)
|
||||
{
|
||||
$meta[(int) $row->content_id] = [
|
||||
'childcount' => (int) $row->childcount,
|
||||
'insertamum' => $row->insertamum,
|
||||
'updateamum' => $row->updateamum,
|
||||
'mine' => ($row->mine === 't' || $row->mine === true),
|
||||
'contentlength' => (int) $row->contentlength
|
||||
];
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess($meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two gates guard every click statistics request: the config flag switches the feature
|
||||
* off for everybody, the admin right decides who may read the numbers.
|
||||
*/
|
||||
private function checkClickstatsAccess()
|
||||
{
|
||||
$this->config->load('cms');
|
||||
|
||||
if (!$this->config->item('clickstats_enabled'))
|
||||
$this->terminateWithError($this->p->t('cms', 'klickstatistikDeaktiviert'));
|
||||
|
||||
if (!$this->permissionlib->isBerechtigt('admin'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Period of a click statistics request. 0 counts the whole log.
|
||||
* Call it after checkClickstatsAccess, which loads the config.
|
||||
* @return array months and the start date, null for the whole log
|
||||
*/
|
||||
private function clickstatsPeriod()
|
||||
{
|
||||
$months = $this->input->get('months');
|
||||
if ($months === null || $months === '')
|
||||
$months = $this->config->item('clickstats_months');
|
||||
$months = (int) $months;
|
||||
|
||||
$since = ($months > 0)
|
||||
? date('Y-m-d H:i:s', strtotime('-' . $months . ' months'))
|
||||
: null;
|
||||
|
||||
return [$months, $since];
|
||||
}
|
||||
|
||||
/**
|
||||
* Views of one content. One counting query, so the tab loads it right away.
|
||||
* Empty if LOG_CONTENT is off.
|
||||
*/
|
||||
public function getClickCount()
|
||||
{
|
||||
$this->checkClickstatsAccess();
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('months', 'Monate', 'is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$this->load->model('system/Webservicelog_model', 'WebservicelogModel');
|
||||
|
||||
list($months, $since) = $this->clickstatsPeriod();
|
||||
|
||||
$result = $this->WebservicelogModel->getContentClickCount(
|
||||
(int) $this->input->get('content_id', TRUE), $since
|
||||
);
|
||||
|
||||
$this->terminateWithSuccess([
|
||||
'since' => $since,
|
||||
'months' => $months,
|
||||
'own' => $this->getDataOrTerminateWithError($result)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ranking of the most viewed contents. It groups the whole log and resolves a
|
||||
* title per row, which takes seconds, so the tab fetches it only on request.
|
||||
* Empty if LOG_CONTENT is off.
|
||||
*/
|
||||
public function getClickRanking()
|
||||
{
|
||||
$this->checkClickstatsAccess();
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('months', 'Monate', 'is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$this->load->model('system/Webservicelog_model', 'WebservicelogModel');
|
||||
|
||||
list($months, $since) = $this->clickstatsPeriod();
|
||||
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
if (empty($sprache))
|
||||
$sprache = DEFAULT_LANGUAGE;
|
||||
|
||||
$result = $this->WebservicelogModel->getContentClickCounts(
|
||||
$since, $sprache, (int) $this->config->item('clickstats_limit')
|
||||
);
|
||||
$rows = $this->getDataOrTerminateWithError($result);
|
||||
|
||||
$ranked = [];
|
||||
foreach ((array) $rows as $row)
|
||||
{
|
||||
$ranked[] = [
|
||||
'content_id' => (int) $row->content_id,
|
||||
'titel' => $row->titel,
|
||||
'hits' => (int) $row->hits
|
||||
];
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess([
|
||||
'since' => $since,
|
||||
'months' => $months,
|
||||
'ranked' => $ranked
|
||||
]);
|
||||
}
|
||||
|
||||
public function getTemplates()
|
||||
{
|
||||
$this->TemplateModel->addSelect('template_kurzbz, bezeichnung');
|
||||
$this->TemplateModel->addOrder('bezeichnung');
|
||||
$result = $this->TemplateModel->loadWhere([]);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function getOrganisationseinheiten()
|
||||
{
|
||||
$this->load->model(
|
||||
'organisation/Organisationseinheit_model', 'OrganisationseinheitModel'
|
||||
);
|
||||
$this->OrganisationseinheitModel->addSelect(
|
||||
'oe_kurzbz, bezeichnung, organisationseinheittyp_kurzbz, aktiv'
|
||||
);
|
||||
$this->OrganisationseinheitModel->addOrder('bezeichnung');
|
||||
$result = $this->OrganisationseinheitModel->loadWhere([]);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function getSprachen()
|
||||
{
|
||||
$this->load->model('system/Sprache_model', 'SpracheModel');
|
||||
$this->SpracheModel->addSelect('sprache, bezeichnung');
|
||||
|
||||
$result = $this->SpracheModel->loadWhere(['content' => true]);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function getUsage()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$result = $this->ContentModel->getUsage($content_id);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function postContent()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('parent_content_id', 'Parent', 'is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$parent = $this->input->post('parent_content_id');
|
||||
if (empty($parent))
|
||||
$parent = null;
|
||||
|
||||
$result = $this->cmsadminlib->createContent($parent);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function postTranslation()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
|
||||
$this->form_validation->set_rules('target_sprache', 'Target Sprache', 'required');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$result = $this->cmsadminlib->createTranslation(
|
||||
$this->input->post('content_id'),
|
||||
$this->input->post('sprache'),
|
||||
$this->input->post('version'),
|
||||
$this->input->post('target_sprache')
|
||||
);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function postVersion()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$result = $this->cmsadminlib->createVersion(
|
||||
$this->input->post('content_id'),
|
||||
$this->input->post('sprache')
|
||||
);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function putProperties()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
|
||||
$this->form_validation->set_rules('template_kurzbz', 'Template', 'required');
|
||||
$this->form_validation->set_rules('oe_kurzbz', 'OE', 'required');
|
||||
$this->form_validation->set_rules('titel', 'Titel', 'required|max_length[256]');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$daten = [
|
||||
'content_id' => $this->input->post('content_id'),
|
||||
'sprache' => $this->input->post('sprache'),
|
||||
'version' => $this->input->post('version'),
|
||||
'template_kurzbz' => $this->input->post('template_kurzbz'),
|
||||
'oe_kurzbz' => $this->input->post('oe_kurzbz'),
|
||||
'titel' => $this->input->post('titel'),
|
||||
'beschreibung' => $this->input->post('beschreibung') ?: '',
|
||||
'aktiv' => filter_var($this->input->post('aktiv'), FILTER_VALIDATE_BOOLEAN),
|
||||
'menu_open' => filter_var($this->input->post('menu_open'), FILTER_VALIDATE_BOOLEAN),
|
||||
'sichtbar' => filter_var($this->input->post('sichtbar'), FILTER_VALIDATE_BOOLEAN)
|
||||
];
|
||||
|
||||
$result = $this->cmsadminlib->saveProperties($daten);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function deleteContent()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'd'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$result = $this->cmsadminlib->deleteContent($this->input->post('content_id'));
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function deleteContentsprache()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'd'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->validateContentSpracheVersion();
|
||||
|
||||
$result = $this->cmsadminlib->deleteVersion(
|
||||
$this->input->post('content_id'),
|
||||
$this->input->post('sprache'),
|
||||
$this->input->post('version')
|
||||
);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private function validateContentSpracheVersion()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
}
|
||||
|
||||
private function buildTreeNode($content_id, $depth, &$visited)
|
||||
{
|
||||
if ($depth > self::MAX_TREE_DEPTH || isset($visited[$content_id]))
|
||||
return null;
|
||||
|
||||
$visited[$content_id] = true;
|
||||
|
||||
$this->ContentModel->resetQuery();
|
||||
$contentResult = $this->ContentModel->load($content_id);
|
||||
if (isError($contentResult) || empty(getData($contentResult)))
|
||||
return null;
|
||||
$content = getData($contentResult)[0];
|
||||
|
||||
$titel = '';
|
||||
$csResult = $this->ContentspracheModel->getOne($content_id, DEFAULT_LANGUAGE);
|
||||
if (!isError($csResult))
|
||||
$titel = getData($csResult)->titel;
|
||||
|
||||
$childrenResult = $this->ContentchildModel->getChilds($content_id, DEFAULT_LANGUAGE);
|
||||
$children = [];
|
||||
if (!isError($childrenResult) && getData($childrenResult))
|
||||
{
|
||||
foreach (getData($childrenResult) as $child)
|
||||
{
|
||||
$childNode = $this->buildTreeNode(
|
||||
(int) $child->child_content_id, $depth + 1, $visited
|
||||
);
|
||||
if ($childNode !== null)
|
||||
$children[] = $childNode;
|
||||
}
|
||||
}
|
||||
|
||||
$node = new stdClass();
|
||||
$node->content_id = (int) $content_id;
|
||||
$node->titel = $titel;
|
||||
$node->template_kurzbz = $content->template_kurzbz;
|
||||
$node->oe_kurzbz = $content->oe_kurzbz;
|
||||
$node->aktiv = ($content->aktiv === 't' || $content->aktiv === true);
|
||||
$node->entitled = false;
|
||||
$node->groups = [];
|
||||
$node->children = $children;
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function collectContentIds($nodes, &$ids)
|
||||
{
|
||||
foreach ($nodes as $node)
|
||||
{
|
||||
$ids[] = $node->content_id;
|
||||
if (!empty($node->children))
|
||||
$this->collectContentIds($node->children, $ids);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyMeta(&$nodes, $entitledOe, $groupMap)
|
||||
{
|
||||
foreach ($nodes as $node)
|
||||
{
|
||||
$node->entitled = in_array($node->oe_kurzbz, $entitledOe);
|
||||
$node->groups = isset($groupMap[$node->content_id])
|
||||
? $groupMap[$node->content_id]
|
||||
: [];
|
||||
if (!empty($node->children))
|
||||
$this->applyMeta($node->children, $entitledOe, $groupMap);
|
||||
}
|
||||
}
|
||||
|
||||
private function pruneUnentitled($node)
|
||||
{
|
||||
$kept = [];
|
||||
foreach ($node->children as $child)
|
||||
{
|
||||
$pruned = $this->pruneUnentitled($child);
|
||||
if ($pruned !== null)
|
||||
$kept[] = $pruned;
|
||||
}
|
||||
$node->children = $kept;
|
||||
|
||||
if ($node->entitled || !empty($node->children))
|
||||
return $node;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the DMS categories for the contentcomponent insert dialog.
|
||||
*/
|
||||
public function getDmsKategorien()
|
||||
{
|
||||
$this->load->model('content/Dms_model', 'DmsModel');
|
||||
|
||||
$result = $this->DmsModel->getKategorien();
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Lists active staff for the contentcomponent insert dialog.
|
||||
* Names only. See Mitarbeiter_model::getAktivesPersonalNamen().
|
||||
*/
|
||||
public function getMitarbeiter()
|
||||
{
|
||||
$this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
|
||||
|
||||
$result = $this->MitarbeiterModel->getAktivesPersonalNamen();
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Lists the documents of one DMS category for the contentcomponent insert dialog.
|
||||
*/
|
||||
public function getDmsKategorieDokumente()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required');
|
||||
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$this->load->model('content/Dms_model', 'DmsModel');
|
||||
|
||||
$result = $this->DmsModel->getKategorieDokumenteAlle($this->input->get('kategorie_kurzbz', TRUE));
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result) ?: array());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
<?php
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
class CmsAdminInhalt extends FHCAPI_Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct([
|
||||
'getFormSchema' => ['basis/cms:r'],
|
||||
'getFormData' => ['basis/cms:r'],
|
||||
'getLock' => ['basis/cms:r'],
|
||||
'getVersions' => ['basis/cms:r'],
|
||||
'putFormData' => ['basis/cms:rw'],
|
||||
'postLock' => ['basis/cms:rw'],
|
||||
'deleteLock' => ['basis/cms:rw'],
|
||||
'deleteLockForced' => ['basis/cms:rw']
|
||||
]);
|
||||
|
||||
$this->load->library('CmsAdminLib');
|
||||
$this->load->library('XsdSchemaLib');
|
||||
$this->load->library('PermissionLib');
|
||||
$this->load->model('content/Contentsprache_model', 'ContentspracheModel');
|
||||
$this->load->model('content/Template_model', 'TemplateModel');
|
||||
|
||||
$this->loadPhrases(['global', 'cms']);
|
||||
}
|
||||
|
||||
public function getFormSchema()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('template_kurzbz', 'Template', 'required');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$template_kurzbz = $this->input->get('template_kurzbz', TRUE);
|
||||
|
||||
$this->TemplateModel->resetQuery();
|
||||
$templateResult = $this->TemplateModel->load($template_kurzbz);
|
||||
$templateData = $this->getDataOrTerminateWithError($templateResult);
|
||||
if (empty($templateData))
|
||||
$this->terminateWithError($this->p->t('cms', 'vorlageNichtGefunden'));
|
||||
|
||||
$schemaResult = $this->xsdschemalib->parseSchema(
|
||||
$templateData[0]->xsd, $template_kurzbz
|
||||
);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($schemaResult));
|
||||
}
|
||||
|
||||
public function getFormData()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
$version = $this->input->get('version', TRUE);
|
||||
|
||||
$versionResult = $this->ContentspracheModel->getOne($content_id, $sprache, $version);
|
||||
$row = $this->getDataOrTerminateWithError($versionResult);
|
||||
|
||||
$this->load->model('content/Content_model', 'ContentModel');
|
||||
$this->ContentModel->resetQuery();
|
||||
$contentResult = $this->ContentModel->load($content_id);
|
||||
$contentData = $this->getDataOrTerminateWithError($contentResult);
|
||||
if (empty($contentData))
|
||||
$this->terminateWithError($this->p->t('cms', 'contentNichtGefunden'));
|
||||
|
||||
$this->TemplateModel->resetQuery();
|
||||
$templateResult = $this->TemplateModel->load($contentData[0]->template_kurzbz);
|
||||
$templateData = $this->getDataOrTerminateWithError($templateResult);
|
||||
if (empty($templateData))
|
||||
$this->terminateWithError($this->p->t('cms', 'vorlageNichtGefunden'));
|
||||
|
||||
$schemaResult = $this->xsdschemalib->parseSchema(
|
||||
$templateData[0]->xsd, $contentData[0]->template_kurzbz
|
||||
);
|
||||
$schema = $this->getDataOrTerminateWithError($schemaResult);
|
||||
|
||||
$valuesResult = $this->xsdschemalib->extractValues($row->content, $schema);
|
||||
$values = $this->getDataOrTerminateWithError($valuesResult);
|
||||
|
||||
$lockResult = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
|
||||
$lock = $this->getDataOrTerminateWithError($lockResult);
|
||||
|
||||
$this->terminateWithSuccess([
|
||||
'schema' => $schema,
|
||||
'values' => $values,
|
||||
'sperre' => $lock
|
||||
]);
|
||||
}
|
||||
|
||||
public function getLock()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
$version = $this->input->get('version', TRUE);
|
||||
|
||||
$result = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function getVersions()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
|
||||
$result = $this->ContentspracheModel->getVersions($content_id, $sprache);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function putFormData()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
|
||||
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->post('content_id');
|
||||
$sprache = $this->input->post('sprache');
|
||||
$version = $this->input->post('version');
|
||||
$values = $this->input->post('values');
|
||||
|
||||
if (is_string($values))
|
||||
$values = json_decode($values, true);
|
||||
|
||||
if (!is_array($values))
|
||||
$values = [];
|
||||
|
||||
$lockResult = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
|
||||
$lock = $this->getDataOrTerminateWithError($lockResult);
|
||||
if (!$lock['own'])
|
||||
$this->terminateWithError($this->p->t('cms', 'speichernNurMitSperre'));
|
||||
|
||||
$result = $this->cmsadminlib->saveContentXml($content_id, $sprache, $version, $values);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function postLock()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
|
||||
|
||||
$contentsprache_id = $this->input->post('contentsprache_id');
|
||||
|
||||
$result = $this->cmsadminlib->lock($contentsprache_id);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function deleteLock()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
|
||||
|
||||
$contentsprache_id = $this->input->post('contentsprache_id');
|
||||
|
||||
$result = $this->cmsadminlib->releaseOwnLock($contentsprache_id);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function deleteLockForced()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
|
||||
|
||||
$contentsprache_id = $this->input->post('contentsprache_id');
|
||||
|
||||
$result = $this->cmsadminlib->forceRelease($contentsprache_id);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
<?php
|
||||
|
||||
if (! defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
class CmsAdminStruktur extends FHCAPI_Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct([
|
||||
'getGruppen' => ['basis/cms:r'],
|
||||
'getAllGruppen' => ['basis/cms:r'],
|
||||
'getChilds' => ['basis/cms:r'],
|
||||
'getParents' => ['basis/cms:r'],
|
||||
'getPossibleChilds' => ['basis/cms:r'],
|
||||
'postGruppe' => ['basis/cms:rw'],
|
||||
'deleteGruppe' => ['basis/cms:rw'],
|
||||
'postChild' => ['basis/cms:rw'],
|
||||
'deleteChild' => ['basis/cms:rw'],
|
||||
'putChildSort' => ['basis/cms:rw'],
|
||||
'putChildOrder' => ['basis/cms:rw']
|
||||
]);
|
||||
|
||||
$this->load->library('PermissionLib');
|
||||
$this->load->model('content/Content_model', 'ContentModel');
|
||||
$this->load->model('content/Contentchild_model', 'ContentchildModel');
|
||||
$this->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
|
||||
|
||||
$this->loadPhrases(['global', 'cms']);
|
||||
}
|
||||
|
||||
public function getGruppen()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
|
||||
$result = $this->ContentgruppeModel->getGruppen($content_id);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function getAllGruppen()
|
||||
{
|
||||
$result = $this->ContentgruppeModel->getAllGruppen();
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function getChilds()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
|
||||
if (empty($sprache))
|
||||
$sprache = DEFAULT_LANGUAGE;
|
||||
|
||||
$result = $this->ContentchildModel->getChilds($content_id, $sprache);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function getParents()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
|
||||
if (empty($sprache))
|
||||
$sprache = DEFAULT_LANGUAGE;
|
||||
|
||||
$result = $this->ContentchildModel->getParents($content_id, $sprache);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function getPossibleChilds()
|
||||
{
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_GET);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->get('content_id', TRUE);
|
||||
$sprache = $this->input->get('sprache', TRUE);
|
||||
|
||||
if (empty($sprache))
|
||||
$sprache = DEFAULT_LANGUAGE;
|
||||
|
||||
$result = $this->ContentModel->getPossibleChilds($content_id, $sprache);
|
||||
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
|
||||
}
|
||||
|
||||
public function postGruppe()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('gruppe_kurzbz', 'Gruppe', 'required');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$result = $this->ContentgruppeModel->insert([
|
||||
'content_id' => $this->input->post('content_id'),
|
||||
'gruppe_kurzbz' => $this->input->post('gruppe_kurzbz'),
|
||||
'insertamum' => date('Y-m-d H:i:s'),
|
||||
'insertvon' => getAuthUID()
|
||||
]);
|
||||
|
||||
if (isError($result))
|
||||
{
|
||||
$this->terminateWithError($this->p->t('cms', 'gruppeBereitsZugeordnet'));
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess(true);
|
||||
}
|
||||
|
||||
public function deleteGruppe()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('gruppe_kurzbz', 'Gruppe', 'required');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$result = $this->ContentgruppeModel->delete([
|
||||
$this->input->post('gruppe_kurzbz'),
|
||||
$this->input->post('content_id')
|
||||
]);
|
||||
$this->getDataOrTerminateWithError($result);
|
||||
|
||||
$this->terminateWithSuccess(true);
|
||||
}
|
||||
|
||||
public function postChild()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('child_content_id', 'Child Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$content_id = $this->input->post('content_id');
|
||||
|
||||
$sortResult = $this->ContentchildModel->getMaxSort($content_id);
|
||||
$sort = $this->getDataOrTerminateWithError($sortResult) + 1;
|
||||
|
||||
$result = $this->ContentchildModel->insert([
|
||||
'content_id' => $content_id,
|
||||
'child_content_id' => $this->input->post('child_content_id'),
|
||||
'sort' => $sort,
|
||||
'insertamum' => date('Y-m-d H:i:s'),
|
||||
'insertvon' => getAuthUID()
|
||||
]);
|
||||
$this->getDataOrTerminateWithError($result);
|
||||
|
||||
$this->terminateWithSuccess(true);
|
||||
}
|
||||
|
||||
public function deleteChild()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('contentchild_id', 'Contentchild ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$result = $this->ContentchildModel->delete($this->input->post('contentchild_id'));
|
||||
$this->getDataOrTerminateWithError($result);
|
||||
|
||||
$this->terminateWithSuccess(true);
|
||||
}
|
||||
|
||||
public function putChildSort()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('contentchild_id', 'Contentchild ID', 'required|is_natural');
|
||||
$this->form_validation->set_rules('direction', 'Direction', 'required|in_list[up,down]');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$direction = $this->input->post('direction');
|
||||
|
||||
$result = $this->ContentchildModel->swapSort(
|
||||
$this->input->post('contentchild_id'),
|
||||
$direction
|
||||
);
|
||||
|
||||
if (isError($result))
|
||||
{
|
||||
// The child already sits at the end it was moved towards. That is a normal
|
||||
// answer, not a failure, and the direction decides the wording.
|
||||
if (getError($result) === Contentchild_model::NO_NEIGHBOUR)
|
||||
{
|
||||
$this->terminateWithError($this->p->t('cms',
|
||||
$direction === 'up' ? 'bereitsGanzOben' : 'bereitsGanzUnten'));
|
||||
}
|
||||
|
||||
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
|
||||
}
|
||||
|
||||
$this->terminateWithSuccess(true);
|
||||
}
|
||||
|
||||
// Writes the whole order at once. The drag and drop moves a child over any distance,
|
||||
// and putChildSort would need one request per position.
|
||||
public function putChildOrder()
|
||||
{
|
||||
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
|
||||
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->form_validation->set_data($_POST);
|
||||
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
$this->terminateWithValidationErrors($this->form_validation->error_array());
|
||||
|
||||
$ids = $this->input->post('contentchild_ids');
|
||||
|
||||
if (is_string($ids))
|
||||
$ids = json_decode($ids, true);
|
||||
|
||||
if (!is_array($ids) || empty($ids))
|
||||
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
|
||||
|
||||
$result = $this->ContentchildModel->setSortOrder(
|
||||
$this->input->post('content_id'),
|
||||
$ids
|
||||
);
|
||||
|
||||
if (isError($result))
|
||||
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
|
||||
|
||||
$this->terminateWithSuccess(true);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,9 @@ class Widget extends FHCAPI_Controller
|
||||
$tmpsetup = json_decode($widget->setup);
|
||||
$tmpsetup->file = absoluteJsImportUrl($tmpsetup->file);
|
||||
$widget->setup = $tmpsetup;
|
||||
$widget->permitted = empty($widget->berechtigung_kurzbz)
|
||||
|| $this->permissionlib->isBerechtigt($widget->berechtigung_kurzbz);
|
||||
|
||||
return $widget;
|
||||
}, $widgets);
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ class Studiensemester extends FHCAPI_Controller
|
||||
'getStudienjahrByStudiensemester' => self::PERM_LOGGED,
|
||||
'getAllStudiensemesterAndAktOrNext' => self::PERM_LOGGED,
|
||||
'current' => self::PERM_LOGGED,
|
||||
'set' => self::PERM_LOGGED
|
||||
'set' => self::PERM_LOGGED,
|
||||
'getContainingOrNearestByDateRange' => self::PERM_LOGGED,
|
||||
)
|
||||
);
|
||||
// Load model StudiensemesterModel
|
||||
@@ -203,4 +204,15 @@ class Studiensemester extends FHCAPI_Controller
|
||||
|
||||
$this->terminateWithSuccess(true);
|
||||
}
|
||||
|
||||
public function getContainingOrNearestByDateRange()
|
||||
{
|
||||
$from = $this->input->get("from");
|
||||
$to = $this->input->get("to");
|
||||
|
||||
$result = $this->StudiensemesterModel->getContainingOrNearestByDateRange($from, $to);
|
||||
$data = $this->getDataOrTerminateWithError($result);
|
||||
|
||||
$this->terminateWithSuccess($data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
<?php
|
||||
|
||||
if (!defined('BASEPATH'))
|
||||
exit('No direct script access allowed');
|
||||
|
||||
class CmsAdminLib
|
||||
{
|
||||
private $ci;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->ci =& get_instance();
|
||||
|
||||
$this->ci->load->model('content/Content_model', 'ContentModel');
|
||||
$this->ci->load->model('content/Contentsprache_model', 'ContentspracheModel');
|
||||
$this->ci->load->model('content/Contentchild_model', 'ContentchildModel');
|
||||
$this->ci->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
|
||||
$this->ci->load->model('content/Contentlog_model', 'ContentlogModel');
|
||||
$this->ci->load->model('content/Template_model', 'TemplateModel');
|
||||
|
||||
$this->ci->load->library('XsdSchemaLib');
|
||||
$this->ci->load->library('PermissionLib');
|
||||
$this->ci->config->load('cms');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return object success(array) entitled oe_kurzbz values
|
||||
*/
|
||||
public function getEntitledOe()
|
||||
{
|
||||
$oe = $this->ci->permissionlib->getOE_isEntitledFor('basis/cms');
|
||||
|
||||
if ($oe === false || !is_array($oe))
|
||||
return success([]);
|
||||
|
||||
return success($oe);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @return object success(bool)
|
||||
*/
|
||||
public function isEntitledForContent($content_id)
|
||||
{
|
||||
$oeResult = $this->ci->ContentModel->getOeKurzbz($content_id);
|
||||
if (isError($oeResult))
|
||||
return $oeResult;
|
||||
|
||||
$entitledResult = $this->getEntitledOe();
|
||||
if (isError($entitledResult))
|
||||
return $entitledResult;
|
||||
|
||||
return success(in_array(getData($oeResult), getData($entitledResult)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return object success(string) or error
|
||||
*/
|
||||
public function getDefaultOe()
|
||||
{
|
||||
$default = $this->ci->config->item('default_oe_kurzbz');
|
||||
|
||||
$entitledResult = $this->getEntitledOe();
|
||||
if (isError($entitledResult))
|
||||
return $entitledResult;
|
||||
$entitled = getData($entitledResult);
|
||||
|
||||
if (empty($entitled))
|
||||
return error($this->ci->p->t('cms', 'keineBerechtigteOe'));
|
||||
|
||||
if (in_array($default, $entitled))
|
||||
return success($default);
|
||||
|
||||
return success($entitled[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $parent_content_id
|
||||
* @return object success(array) with content_id, sprache, version
|
||||
*/
|
||||
public function createContent($parent_content_id = null)
|
||||
{
|
||||
$templatesResult = $this->ci->TemplateModel->loadWhere([]);
|
||||
if (isError($templatesResult))
|
||||
return $templatesResult;
|
||||
$templates = getData($templatesResult);
|
||||
if (empty($templates))
|
||||
return error($this->ci->p->t('cms', 'keineVorlageVorhanden'));
|
||||
$template = $templates[0];
|
||||
|
||||
$oeResult = $this->getDefaultOe();
|
||||
if (isError($oeResult))
|
||||
return $oeResult;
|
||||
$oe = getData($oeResult);
|
||||
|
||||
$titel = $this->ci->p->t('cms', 'neuerEintrag');
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$uid = getAuthUID();
|
||||
|
||||
$this->ci->db->trans_start();
|
||||
|
||||
$contentResult = $this->ci->ContentModel->insert([
|
||||
'template_kurzbz' => $template->template_kurzbz,
|
||||
'oe_kurzbz' => $oe,
|
||||
'aktiv' => true,
|
||||
'menu_open' => false,
|
||||
'beschreibung' => '',
|
||||
'insertamum' => $now,
|
||||
'insertvon' => $uid
|
||||
]);
|
||||
if (isError($contentResult))
|
||||
return $contentResult;
|
||||
$content_id = getData($contentResult);
|
||||
|
||||
$spracheResult = $this->ci->ContentspracheModel->insert([
|
||||
'content_id' => $content_id,
|
||||
'sprache' => DEFAULT_LANGUAGE,
|
||||
'version' => 1,
|
||||
'sichtbar' => true,
|
||||
'titel' => $titel,
|
||||
'content' => '<?xml version="1.0" encoding="UTF-8" ?><content></content>',
|
||||
'insertamum' => $now,
|
||||
'insertvon' => $uid
|
||||
]);
|
||||
if (isError($spracheResult))
|
||||
return $spracheResult;
|
||||
|
||||
if ($parent_content_id !== null)
|
||||
{
|
||||
$maxSortResult = $this->ci->ContentchildModel->getMaxSort($parent_content_id);
|
||||
if (isError($maxSortResult))
|
||||
return $maxSortResult;
|
||||
|
||||
$childResult = $this->ci->ContentchildModel->insert([
|
||||
'content_id' => $parent_content_id,
|
||||
'child_content_id' => $content_id,
|
||||
'sort' => getData($maxSortResult) + 1,
|
||||
'insertamum' => $now,
|
||||
'insertvon' => $uid
|
||||
]);
|
||||
if (isError($childResult))
|
||||
return $childResult;
|
||||
}
|
||||
|
||||
$this->ci->db->trans_complete();
|
||||
|
||||
if ($this->ci->db->trans_status() === false)
|
||||
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
|
||||
|
||||
return success([
|
||||
'content_id' => $content_id,
|
||||
'sprache' => DEFAULT_LANGUAGE,
|
||||
'version' => 1
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @param string $sprache source language
|
||||
* @param int $version source version
|
||||
* @param string $neueSprache target language
|
||||
* @return object success(array) with contentsprache_id
|
||||
*/
|
||||
public function createTranslation($content_id, $sprache, $version, $neueSprache)
|
||||
{
|
||||
$existsResult = $this->ci->ContentspracheModel->exists($content_id, $neueSprache);
|
||||
if (isError($existsResult))
|
||||
return $existsResult;
|
||||
if (getData($existsResult))
|
||||
return error($this->ci->p->t('cms', 'uebersetzungExistiert'));
|
||||
|
||||
$sourceResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
|
||||
if (isError($sourceResult))
|
||||
return $sourceResult;
|
||||
$source = getData($sourceResult);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$uid = getAuthUID();
|
||||
|
||||
$insertResult = $this->ci->ContentspracheModel->insert([
|
||||
'content_id' => $content_id,
|
||||
'sprache' => $neueSprache,
|
||||
'version' => $source->version,
|
||||
'sichtbar' => true,
|
||||
'titel' => $source->titel,
|
||||
'content' => $source->content,
|
||||
// The review feature is gone from this admin, but the columns stay
|
||||
'reviewvon' => null,
|
||||
'reviewamum' => null,
|
||||
'gesperrt_uid' => null,
|
||||
'insertamum' => $now,
|
||||
'insertvon' => $uid,
|
||||
'updateamum' => $now,
|
||||
'updatevon' => $uid
|
||||
]);
|
||||
if (isError($insertResult))
|
||||
return $insertResult;
|
||||
|
||||
return success(['contentsprache_id' => getData($insertResult)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @return object success(array) with version
|
||||
*/
|
||||
public function createVersion($content_id, $sprache)
|
||||
{
|
||||
$sourceResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, null);
|
||||
if (isError($sourceResult))
|
||||
return $sourceResult;
|
||||
$source = getData($sourceResult);
|
||||
|
||||
$maxResult = $this->ci->ContentspracheModel->getMaxVersion($content_id, $sprache);
|
||||
if (isError($maxResult))
|
||||
return $maxResult;
|
||||
$newVersion = getData($maxResult) + 1;
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$uid = getAuthUID();
|
||||
|
||||
$insertResult = $this->ci->ContentspracheModel->insert([
|
||||
'content_id' => $content_id,
|
||||
'sprache' => $sprache,
|
||||
'version' => $newVersion,
|
||||
'sichtbar' => false,
|
||||
'titel' => $source->titel,
|
||||
'content' => $source->content,
|
||||
// The review feature is gone from this admin, but the columns stay
|
||||
'reviewvon' => null,
|
||||
'reviewamum' => null,
|
||||
'gesperrt_uid' => null,
|
||||
'insertamum' => $now,
|
||||
'insertvon' => $uid,
|
||||
'updateamum' => $now,
|
||||
'updatevon' => $uid
|
||||
]);
|
||||
if (isError($insertResult))
|
||||
return $insertResult;
|
||||
|
||||
return success(['version' => $newVersion]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the properties of one content version.
|
||||
* @param array $daten content_id, sprache, version, template_kurzbz, oe_kurzbz,
|
||||
* aktiv, menu_open, beschreibung, titel, sichtbar
|
||||
* @return object success(true) or error
|
||||
*/
|
||||
public function saveProperties($daten)
|
||||
{
|
||||
$versionResult = $this->ci->ContentspracheModel->getOne(
|
||||
$daten['content_id'], $daten['sprache'], $daten['version']
|
||||
);
|
||||
if (isError($versionResult))
|
||||
return $versionResult;
|
||||
$row = getData($versionResult);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$uid = getAuthUID();
|
||||
|
||||
$this->ci->db->trans_start();
|
||||
|
||||
$this->ci->ContentModel->update($daten['content_id'], [
|
||||
'template_kurzbz' => $daten['template_kurzbz'],
|
||||
'oe_kurzbz' => $daten['oe_kurzbz'],
|
||||
'aktiv' => $daten['aktiv'],
|
||||
'menu_open' => $daten['menu_open'],
|
||||
'beschreibung' => $daten['beschreibung'],
|
||||
'updateamum' => $now,
|
||||
'updatevon' => $uid
|
||||
]);
|
||||
|
||||
$this->ci->ContentspracheModel->update($row->contentsprache_id, [
|
||||
'titel' => $daten['titel'],
|
||||
'sichtbar' => $daten['sichtbar'],
|
||||
'updateamum' => $now,
|
||||
'updatevon' => $uid
|
||||
]);
|
||||
|
||||
$this->ci->db->trans_complete();
|
||||
|
||||
if ($this->ci->db->trans_status() === false)
|
||||
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
|
||||
|
||||
return success(true);
|
||||
}
|
||||
|
||||
// legacy cms checks neither the permission
|
||||
// type nor the lock. The form appears only when the user holds the lock, but the POST
|
||||
// itself is unprotected.
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @param int $version
|
||||
* @param array $values field name => value
|
||||
* @return object success(true) or error
|
||||
*/
|
||||
public function saveContentXml($content_id, $sprache, $version, $values)
|
||||
{
|
||||
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
|
||||
if (isError($versionResult))
|
||||
return $versionResult;
|
||||
$row = getData($versionResult);
|
||||
|
||||
$contentResult = $this->ci->ContentModel->load($content_id);
|
||||
if (isError($contentResult))
|
||||
return $contentResult;
|
||||
$content = getData($contentResult);
|
||||
if (empty($content))
|
||||
return error($this->ci->p->t('cms', 'contentNichtGefunden'));
|
||||
|
||||
$templateResult = $this->ci->TemplateModel->load($content[0]->template_kurzbz);
|
||||
if (isError($templateResult))
|
||||
return $templateResult;
|
||||
$template = getData($templateResult);
|
||||
if (empty($template))
|
||||
return error($this->ci->p->t('cms', 'keineVorlageVorhanden'));
|
||||
|
||||
$schemaResult = $this->ci->xsdschemalib->parseSchema(
|
||||
$template[0]->xsd, $content[0]->template_kurzbz
|
||||
);
|
||||
if (isError($schemaResult))
|
||||
return $schemaResult;
|
||||
|
||||
$xmlResult = $this->ci->xsdschemalib->buildXml(getData($schemaResult), $values);
|
||||
if (isError($xmlResult))
|
||||
return $xmlResult;
|
||||
|
||||
$updateResult = $this->ci->ContentspracheModel->update(
|
||||
$row->contentsprache_id,
|
||||
['content' => getData($xmlResult)]
|
||||
);
|
||||
if (isError($updateResult))
|
||||
return $updateResult;
|
||||
|
||||
return success(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks a version for the current user, or takes over an expired lock.
|
||||
* @param int $contentsprache_id
|
||||
* @return object success(true) or error
|
||||
*/
|
||||
public function lock($contentsprache_id)
|
||||
{
|
||||
$uid = getAuthUID();
|
||||
|
||||
$rowResult = $this->ci->ContentspracheModel->load($contentsprache_id);
|
||||
if (isError($rowResult))
|
||||
return $rowResult;
|
||||
$rowData = getData($rowResult);
|
||||
if (empty($rowData))
|
||||
return error($this->ci->p->t('cms', 'versionNichtGefunden'));
|
||||
|
||||
$holder = ($rowData[0]->gesperrt_uid === '') ? null : $rowData[0]->gesperrt_uid;
|
||||
|
||||
if ($holder !== null && $holder !== $uid)
|
||||
{
|
||||
$openResult = $this->ci->ContentlogModel->getOpenLock($contentsprache_id, $holder);
|
||||
$openEntry = (!isError($openResult)) ? getData($openResult) : null;
|
||||
|
||||
if (!$this->isLockExpired($holder, $openEntry))
|
||||
return error($this->ci->p->t('cms', 'bereitsGesperrt'));
|
||||
}
|
||||
|
||||
$this->ci->db->trans_start();
|
||||
|
||||
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id);
|
||||
|
||||
$logResult = $this->ci->ContentlogModel->insert([
|
||||
'uid' => $uid,
|
||||
'contentsprache_id' => $contentsprache_id,
|
||||
'start' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
if (isError($logResult))
|
||||
return $logResult;
|
||||
|
||||
$updateResult = $this->ci->ContentspracheModel->update($contentsprache_id, [
|
||||
'gesperrt_uid' => $uid
|
||||
]);
|
||||
if (isError($updateResult))
|
||||
return $updateResult;
|
||||
|
||||
$this->ci->db->trans_complete();
|
||||
|
||||
if ($this->ci->db->trans_status() === false)
|
||||
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
|
||||
|
||||
return success(true);
|
||||
}
|
||||
|
||||
// legacy released ALL locks of the user. Releasing now ends one lock.
|
||||
// The uid predicate stops a user releasing a lock of somebody else.
|
||||
/**
|
||||
* Releases the lock of the current user on one version.
|
||||
* @param int $contentsprache_id
|
||||
* @return object success(true)
|
||||
*/
|
||||
public function releaseOwnLock($contentsprache_id)
|
||||
{
|
||||
$uid = getAuthUID();
|
||||
|
||||
$this->ci->db->trans_start();
|
||||
|
||||
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id, $uid);
|
||||
$this->ci->ContentspracheModel->releaseLock($contentsprache_id, $uid);
|
||||
|
||||
$this->ci->db->trans_complete();
|
||||
|
||||
if ($this->ci->db->trans_status() === false)
|
||||
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
|
||||
|
||||
return success(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $contentsprache_id
|
||||
* @return object success(true)
|
||||
*/
|
||||
public function forceRelease($contentsprache_id)
|
||||
{
|
||||
$this->ci->db->trans_start();
|
||||
|
||||
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id);
|
||||
$this->ci->ContentspracheModel->releaseLock($contentsprache_id);
|
||||
|
||||
$this->ci->db->trans_complete();
|
||||
|
||||
if ($this->ci->db->trans_status() === false)
|
||||
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
|
||||
|
||||
return success(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @param int $version
|
||||
* @return object success(array) gesperrt_uid, start, expires, own, expired, may_force
|
||||
*/
|
||||
public function getLockState($content_id, $sprache, $version)
|
||||
{
|
||||
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
|
||||
if (isError($versionResult))
|
||||
return $versionResult;
|
||||
$row = getData($versionResult);
|
||||
|
||||
// legacy admin.php inserts an empty string, releasing writes NULL.
|
||||
// this code reports NULL.
|
||||
$gesperrt_uid = ($row->gesperrt_uid === '') ? null : $row->gesperrt_uid;
|
||||
|
||||
$logEntry = null;
|
||||
if ($gesperrt_uid !== null)
|
||||
{
|
||||
$lockResult = $this->ci->ContentlogModel->getOpenLock(
|
||||
$row->contentsprache_id, $gesperrt_uid
|
||||
);
|
||||
$logEntry = (!isError($lockResult)) ? getData($lockResult) : null;
|
||||
}
|
||||
|
||||
return success([
|
||||
'contentsprache_id' => (int) $row->contentsprache_id,
|
||||
'gesperrt_uid' => $gesperrt_uid,
|
||||
'start' => $logEntry ? $logEntry->start : null,
|
||||
'own' => ($gesperrt_uid !== null && $gesperrt_uid === getAuthUID()),
|
||||
'expired' => $this->isLockExpired($gesperrt_uid, $logEntry),
|
||||
'expires' => $this->lockExpiryDate($logEntry),
|
||||
'may_force' => $this->ci->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* End of the lock window, null if the entry carries no start.
|
||||
* @param stdClass|null $logEntry
|
||||
* @return string|null
|
||||
*/
|
||||
private function lockExpiryDate($logEntry)
|
||||
{
|
||||
if ($logEntry === null || empty($logEntry->start))
|
||||
return null;
|
||||
|
||||
$ttlHours = (int) $this->ci->config->item('lock_ttl_hours');
|
||||
|
||||
// lock() writes start with PHP time, so stay on PHP time.
|
||||
return date('Y-m-d H:i:s', strtotime($logEntry->start) + $ttlHours * 3600);
|
||||
}
|
||||
|
||||
// Locks now age out after lock_ttl_hours.
|
||||
/**
|
||||
* A lock without a log entry has no age and counts as expired.
|
||||
* @param string|null $gesperrt_uid
|
||||
* @param stdClass|null $logEntry
|
||||
* @return bool
|
||||
*/
|
||||
private function isLockExpired($gesperrt_uid, $logEntry)
|
||||
{
|
||||
if ($gesperrt_uid === null)
|
||||
return false;
|
||||
|
||||
$expires = $this->lockExpiryDate($logEntry);
|
||||
|
||||
return $expires === null || strtotime($expires) < time();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @return object success(true) or error
|
||||
*/
|
||||
public function deleteContent($content_id)
|
||||
{
|
||||
$this->ci->db->trans_start();
|
||||
|
||||
$this->ci->ContentchildModel->deleteByContent($content_id);
|
||||
$this->ci->ContentlogModel->deleteByContent($content_id);
|
||||
$this->ci->ContentspracheModel->deleteByContent($content_id);
|
||||
$this->ci->ContentgruppeModel->deleteByContent($content_id);
|
||||
$this->ci->ContentModel->delete($content_id);
|
||||
|
||||
$this->ci->db->trans_complete();
|
||||
|
||||
if ($this->ci->db->trans_status() === false)
|
||||
return error($this->ci->p->t('cms', 'loeschenNichtMoeglichReferenzen'));
|
||||
|
||||
return success(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @param int $version
|
||||
* @return object success(true) or error
|
||||
*/
|
||||
public function deleteVersion($content_id, $sprache, $version)
|
||||
{
|
||||
if ($sprache === DEFAULT_LANGUAGE)
|
||||
{
|
||||
$countResult = $this->ci->ContentspracheModel->getNumberOfVersions($content_id, $sprache);
|
||||
if (isError($countResult))
|
||||
return $countResult;
|
||||
if (getData($countResult) === 1)
|
||||
return error($this->ci->p->t('cms', 'letzteVersionNichtLoeschbar'));
|
||||
}
|
||||
|
||||
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
|
||||
if (isError($versionResult))
|
||||
return $versionResult;
|
||||
$row = getData($versionResult);
|
||||
|
||||
$this->ci->db->trans_start();
|
||||
|
||||
$this->ci->ContentlogModel->deleteByContentsprache($row->contentsprache_id);
|
||||
$this->ci->ContentspracheModel->deleteVersion($content_id, $sprache, $version);
|
||||
|
||||
$this->ci->db->trans_complete();
|
||||
|
||||
if ($this->ci->db->trans_status() === false)
|
||||
return error($this->ci->p->t('cms', 'loeschenFehlgeschlagen'));
|
||||
|
||||
return success(true);
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,10 @@ 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, $log = true)
|
||||
public function getContent($content_id, $version = null, $sprache = null, $sichtbar = true)
|
||||
{
|
||||
if (!is_numeric($content_id))
|
||||
return error('ContentID ist ungueltig');
|
||||
@@ -74,7 +72,7 @@ class CmsLib
|
||||
return $content;
|
||||
|
||||
// Legt einen Logeintrag für die Klickstatistik an
|
||||
if ($log && defined('LOG_CONTENT') && LOG_CONTENT) {
|
||||
if (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;
|
||||
|
||||
@@ -7,7 +7,6 @@ use \DateTime as DateTime;
|
||||
use \DateTimeZone as DateTimeZone;
|
||||
use \DateInterval as DateInterval;
|
||||
use \DatePeriod as DatePeriod;
|
||||
use CI3_Events as Events;
|
||||
|
||||
class StundenplanLib
|
||||
{
|
||||
@@ -144,7 +143,7 @@ class StundenplanLib
|
||||
return $stundenplan_data;
|
||||
$stundenplan_data = getData($stundenplan_data) ?? [];
|
||||
|
||||
$function_error = $this->expandObjectInformation($stundenplan_data, true);
|
||||
$function_error = $this->expandObjectInformation($stundenplan_data);
|
||||
if ($function_error)
|
||||
return $function_error;
|
||||
|
||||
@@ -304,7 +303,7 @@ class StundenplanLib
|
||||
return success($lektoren);
|
||||
}
|
||||
|
||||
public function expandObjectInformation($data, $callExtensionsEvent = false)
|
||||
public function expandObjectInformation($data)
|
||||
{
|
||||
$this->_ci =& get_instance();
|
||||
|
||||
@@ -393,20 +392,6 @@ class StundenplanLib
|
||||
$item->lektor = $lektor_obj_array;
|
||||
|
||||
}
|
||||
|
||||
// info retrieved from extensions might not be necessary for some stundenplan use cases so the event
|
||||
// will be gated behind this flag
|
||||
|
||||
if($callExtensionsEvent) {
|
||||
Events::trigger(
|
||||
'extendStundenplanData',
|
||||
function & () use (&$data)
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function fetchFerienTageEvents($start_date, $end_date, $studiengang_kz)
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
<?php
|
||||
|
||||
if (!defined('BASEPATH'))
|
||||
exit('No direct script access allowed');
|
||||
|
||||
use \DOMDocument as DOMDocument;
|
||||
|
||||
class XsdSchemaLib
|
||||
{
|
||||
private $ci;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->ci =& get_instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an XSD into a field schema.
|
||||
* @param string $xsd the XSD as a string
|
||||
* @param string $templateKurzbz short name of the template
|
||||
* @return object success(array) with template_kurzbz, root, fields
|
||||
*/
|
||||
public function parseSchema($xsd, $templateKurzbz)
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument();
|
||||
|
||||
if (!$dom->loadXML($xsd))
|
||||
{
|
||||
libxml_clear_errors();
|
||||
return error($this->ci->p->t('cms', 'xsdUngueltig'));
|
||||
}
|
||||
|
||||
libxml_clear_errors();
|
||||
|
||||
$root = null;
|
||||
$fields = array();
|
||||
$this->_walkElements($dom->documentElement, $root, $fields);
|
||||
|
||||
return success(array(
|
||||
'template_kurzbz' => $templateKurzbz,
|
||||
'root' => $root,
|
||||
'fields' => $fields
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the values of a content from the XML.
|
||||
* @param string $xml the value of tbl_contentsprache.content
|
||||
* @param array $schema the result of parseSchema
|
||||
* @return object success(array) field name => value
|
||||
*/
|
||||
public function extractValues($xml, $schema)
|
||||
{
|
||||
$values = array();
|
||||
|
||||
foreach ($schema['fields'] as $field)
|
||||
{
|
||||
$values[$field['name']] = '';
|
||||
}
|
||||
|
||||
if ($xml === null || trim($xml) === '')
|
||||
{
|
||||
return success($values);
|
||||
}
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument();
|
||||
|
||||
if (!$dom->loadXML($xml))
|
||||
{
|
||||
libxml_clear_errors();
|
||||
return success($values);
|
||||
}
|
||||
|
||||
libxml_clear_errors();
|
||||
|
||||
foreach ($schema['fields'] as $field)
|
||||
{
|
||||
$nodes = $dom->getElementsByTagName($field['name']);
|
||||
if ($nodes->length > 0)
|
||||
{
|
||||
$values[$field['name']] = $nodes->item(0)->nodeValue;
|
||||
}
|
||||
}
|
||||
|
||||
return success($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the XML from the values.
|
||||
* @param array $schema the result of parseSchema
|
||||
* @param array $values field name => value
|
||||
* @return object success(string) the XML
|
||||
*/
|
||||
public function buildXml($schema, $values)
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
|
||||
$xml .= "\n<" . $schema['root'] . '>';
|
||||
|
||||
foreach ($schema['fields'] as $field)
|
||||
{
|
||||
$value = isset($values[$field['name']]) ? $values[$field['name']] : '';
|
||||
$xml .= "\n<" . $field['name'] . '><![CDATA[' . $this->_escapeCdata($value) . ']]></' . $field['name'] . '>';
|
||||
}
|
||||
|
||||
$xml .= "\n</" . $schema['root'] . '>';
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$check = new DOMDocument();
|
||||
|
||||
if (!$check->loadXML($xml))
|
||||
{
|
||||
libxml_clear_errors();
|
||||
return error($this->ci->p->t('cms', 'xmlUngueltig'));
|
||||
}
|
||||
|
||||
libxml_clear_errors();
|
||||
|
||||
return success($xml);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private methods
|
||||
|
||||
/**
|
||||
* Walks xs:element nodes recursively.
|
||||
* The first element without a type attribute is the root.
|
||||
* Elements with a type attribute are fields.
|
||||
* xs:complexType and xs:sequence are traversed and ignored.
|
||||
*/
|
||||
private function _walkElements($node, &$root, &$fields)
|
||||
{
|
||||
if ($node->nodeType !== XML_ELEMENT_NODE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$localName = $node->localName;
|
||||
|
||||
if ($localName === 'element')
|
||||
{
|
||||
$typeAttr = $node->getAttribute('type');
|
||||
|
||||
if ($typeAttr === '')
|
||||
{
|
||||
if ($root === null)
|
||||
{
|
||||
$root = $node->getAttribute('name');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$minOccurs = $node->getAttribute('minOccurs');
|
||||
$required = ($minOccurs === '') ? true : ((int)$minOccurs > 0);
|
||||
|
||||
$fields[] = array(
|
||||
'name' => $node->getAttribute('name'),
|
||||
'type' => $this->_mapType($typeAttr),
|
||||
'required' => $required
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($node->childNodes as $child)
|
||||
{
|
||||
$this->_walkElements($child, $root, $fields);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an XSD type to the field schema type.
|
||||
*/
|
||||
private function _mapType($xsdType)
|
||||
{
|
||||
$map = array(
|
||||
'xs:string' => 'string',
|
||||
'xs:decimal' => 'string',
|
||||
'xs:integer' => 'string',
|
||||
'xs:positiveInteger' => 'string',
|
||||
'xs:date' => 'date',
|
||||
'wysiwyg' => 'wysiwyg',
|
||||
'file' => 'file',
|
||||
'boolean' => 'boolean'
|
||||
);
|
||||
|
||||
return isset($map[$xsdType]) ? $map[$xsdType] : 'string';
|
||||
}
|
||||
|
||||
// Escapes ]]> in the value. The legacy code does not do this and writes broken XML.
|
||||
private function _escapeCdata($value)
|
||||
{
|
||||
return str_replace(']]>', ']]]]><![CDATA[>', $value);
|
||||
}
|
||||
}
|
||||
@@ -323,266 +323,4 @@ class Content_model extends DB_Model
|
||||
|
||||
return success(isset($result[$root_content_id]) ? $result[$root_content_id] : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root contents without a parent in tbl_contentchild. Excludes news.
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getRootContent()
|
||||
{
|
||||
$query = '
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT DISTINCT ON (content_id) *
|
||||
FROM campus.tbl_content
|
||||
LEFT JOIN campus.tbl_contentchild USING (content_id)
|
||||
WHERE tbl_content.template_kurzbz <> ?
|
||||
AND content_id NOT IN (
|
||||
SELECT child_content_id FROM campus.tbl_contentchild
|
||||
WHERE child_content_id = tbl_content.content_id)
|
||||
) AS a
|
||||
ORDER BY sort, content_id
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, ['news']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent news from the last two months, max 100 rows.
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getNewsContent()
|
||||
{
|
||||
$query = "
|
||||
SELECT *
|
||||
FROM campus.tbl_content
|
||||
JOIN campus.tbl_news USING (content_id)
|
||||
WHERE tbl_news.datum >= now() - '2 month'::interval
|
||||
ORDER BY datum DESC
|
||||
LIMIT 100
|
||||
";
|
||||
|
||||
return $this->execReadOnlyQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contents by content_id or titel. Excludes news. Returns content_ids only.
|
||||
* @param array $searchItems search terms
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function searchCms($searchItems)
|
||||
{
|
||||
if (empty($searchItems))
|
||||
return success([]);
|
||||
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
foreach ($searchItems as $term)
|
||||
{
|
||||
$conditions[] = '(content_id::text = ? OR lower(titel) LIKE lower(?))';
|
||||
$params[] = $term;
|
||||
$params[] = '%' . $term . '%';
|
||||
}
|
||||
|
||||
$query = '
|
||||
SELECT DISTINCT tbl_content.content_id
|
||||
FROM campus.tbl_contentsprache
|
||||
JOIN campus.tbl_content USING (content_id)
|
||||
WHERE tbl_content.template_kurzbz <> ?
|
||||
AND (' . implode(' OR ', $conditions) . ')
|
||||
ORDER BY content_id
|
||||
';
|
||||
|
||||
array_unshift($params, 'news');
|
||||
|
||||
return $this->execReadOnlyQuery($query, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per content metadata for the tree filter. Excludes news, like getRootContent().
|
||||
* updateamum is the last touch on the content or on any of its versions.
|
||||
*
|
||||
* contentlength counts the visible characters of the newest version, the way getOne()
|
||||
* picks it: the default language first, then the highest version. The XML scaffolding,
|
||||
* the CDATA markers, the HTML tags, the entities and every space drop out, so a content
|
||||
* that nobody ever filled measures 0 whatever empty shape it carries. The title counts
|
||||
* too, so a content with a title and no body measures short instead of zero.
|
||||
*
|
||||
* @param string $uid current user, decides the mine flag
|
||||
* @param string $sprache preferred language of the measured version
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getTreeMeta($uid, $sprache)
|
||||
{
|
||||
$query = "
|
||||
SELECT tbl_content.content_id,
|
||||
(SELECT COUNT(*) FROM campus.tbl_contentchild
|
||||
WHERE tbl_contentchild.content_id = tbl_content.content_id) AS childcount,
|
||||
tbl_content.insertamum,
|
||||
GREATEST(
|
||||
tbl_content.updateamum,
|
||||
(SELECT MAX(updateamum) FROM campus.tbl_contentsprache
|
||||
WHERE tbl_contentsprache.content_id = tbl_content.content_id)
|
||||
) AS updateamum,
|
||||
COALESCE(
|
||||
tbl_content.insertvon = ? OR tbl_content.updatevon = ? OR EXISTS (
|
||||
SELECT 1 FROM campus.tbl_contentsprache
|
||||
WHERE tbl_contentsprache.content_id = tbl_content.content_id
|
||||
AND (tbl_contentsprache.insertvon = ?
|
||||
OR tbl_contentsprache.updatevon = ?)
|
||||
),
|
||||
false
|
||||
) AS mine,
|
||||
(SELECT length(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
replace(replace(cs.content::text, '<![CDATA[', ''), ']]>', ''),
|
||||
'<[^>]*>', '', 'g'),
|
||||
'&[a-zA-Z]+;|&#[0-9]+;', '', 'g'),
|
||||
'\s+', '', 'g')
|
||||
)
|
||||
FROM campus.tbl_contentsprache cs
|
||||
WHERE cs.content_id = tbl_content.content_id
|
||||
ORDER BY (cs.sprache = ?) DESC, cs.version DESC
|
||||
LIMIT 1) AS contentlength
|
||||
FROM campus.tbl_content
|
||||
WHERE tbl_content.template_kurzbz <> ?
|
||||
";
|
||||
|
||||
return $this->execReadOnlyQuery(
|
||||
$query, [$uid, $uid, $uid, $uid, $sprache, 'news']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All contents eligible as children: excludes ancestors, self, and news.
|
||||
* @param int $content_id the content to find children for
|
||||
* @param string $sprache language for the titel subselect
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getPossibleChilds($content_id, $sprache)
|
||||
{
|
||||
$query = '
|
||||
SELECT content_id, oe_kurzbz, template_kurzbz,
|
||||
(SELECT titel FROM campus.tbl_contentsprache
|
||||
WHERE sprache = ? AND content_id = tbl_content.content_id
|
||||
ORDER BY version LIMIT 1) AS titel
|
||||
FROM campus.tbl_content
|
||||
WHERE content_id NOT IN (
|
||||
WITH RECURSIVE parents(content_id, child_content_id) AS (
|
||||
SELECT content_id, child_content_id FROM campus.tbl_contentchild
|
||||
WHERE child_content_id = ?
|
||||
UNION ALL
|
||||
SELECT cc.content_id, cc.child_content_id
|
||||
FROM campus.tbl_contentchild cc, parents
|
||||
WHERE cc.child_content_id = parents.content_id)
|
||||
SELECT content_id FROM parents GROUP BY content_id)
|
||||
AND content_id <> ?
|
||||
AND template_kurzbz <> ?
|
||||
ORDER BY titel
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, [$sprache, $content_id, $content_id, 'news']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a content is referenced. Returns [{table, label}] for the delete dialog.
|
||||
* Tolerates missing addon/testtool schemas.
|
||||
* @param int $content_id
|
||||
* @return stdClass success with flat array or error
|
||||
*/
|
||||
public function getUsage($content_id)
|
||||
{
|
||||
$coreQuery = "
|
||||
SELECT 'campus.tbl_infoscreen_content' AS \"table\",
|
||||
infoscreen_id::text AS label
|
||||
FROM campus.tbl_infoscreen_content WHERE content_id = ?
|
||||
UNION ALL
|
||||
SELECT 'campus.tbl_news', betreff
|
||||
FROM campus.tbl_news WHERE content_id = ?
|
||||
UNION ALL
|
||||
SELECT 'public.tbl_ort', ort_kurzbz
|
||||
FROM public.tbl_ort WHERE content_id = ?
|
||||
UNION ALL
|
||||
SELECT 'public.tbl_service', bezeichnung
|
||||
FROM public.tbl_service WHERE content_id = ?
|
||||
UNION ALL
|
||||
SELECT 'public.tbl_statistik', bezeichnung
|
||||
FROM public.tbl_statistik WHERE content_id = ?
|
||||
";
|
||||
$coreParams = [$content_id, $content_id, $content_id, $content_id, $content_id];
|
||||
|
||||
$coreResult = $this->execReadOnlyQuery($coreQuery, $coreParams);
|
||||
$rows = [];
|
||||
|
||||
if (!isError($coreResult) && getData($coreResult))
|
||||
$rows = getData($coreResult);
|
||||
|
||||
// Optional schemas: testtool and addon may not exist. A failing query still writes a
|
||||
// db error into the response envelope, so check for the table before querying it.
|
||||
$optionalQueries = [
|
||||
[
|
||||
'testtool', 'tbl_ablauf_vorgaben',
|
||||
"SELECT 'testtool.tbl_ablauf_vorgaben' AS \"table\",
|
||||
ablauf_vorgaben_id::text AS label
|
||||
FROM testtool.tbl_ablauf_vorgaben WHERE content_id = ?"
|
||||
],
|
||||
[
|
||||
'addon', 'tbl_software',
|
||||
"SELECT 'addon.tbl_software' AS \"table\",
|
||||
software_id::text AS label
|
||||
FROM addon.tbl_software WHERE content_id = ?"
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($optionalQueries as $opt)
|
||||
{
|
||||
if (!$this->tableExists($opt[0], $opt[1]))
|
||||
continue;
|
||||
|
||||
$result = $this->execReadOnlyQuery($opt[2], [$content_id]);
|
||||
if (!isError($result) && getData($result))
|
||||
$rows = array_merge($rows, getData($result));
|
||||
}
|
||||
|
||||
return success($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a base table exists. Guards the optional testtool and addon schemas.
|
||||
* @param string $schema
|
||||
* @param string $table
|
||||
* @return bool
|
||||
*/
|
||||
private function tableExists($schema, $table)
|
||||
{
|
||||
$query = "
|
||||
SELECT 1 AS exists
|
||||
FROM information_schema.tables
|
||||
WHERE table_catalog = ? AND table_schema = ? AND table_name = ?
|
||||
";
|
||||
|
||||
return hasData($this->execReadOnlyQuery($query, [DB_NAME, $schema, $table]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns oe_kurzbz for a content as a string.
|
||||
* @param int $content_id
|
||||
* @return stdClass success with string or error
|
||||
*/
|
||||
public function getOeKurzbz($content_id)
|
||||
{
|
||||
$query = 'SELECT oe_kurzbz FROM campus.tbl_content WHERE content_id = ?';
|
||||
$result = $this->execReadOnlyQuery($query, [$content_id]);
|
||||
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
$data = getData($result);
|
||||
if (empty($data))
|
||||
return error('Content not found');
|
||||
|
||||
return success($data[0]->oe_kurzbz);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<?php
|
||||
class Contentchild_model extends DB_Model
|
||||
{
|
||||
// swapSort reports the two boundary cases with this. See swapSort().
|
||||
const NO_NEIGHBOUR = 'no neighbour';
|
||||
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
@@ -14,345 +11,4 @@ class Contentchild_model extends DB_Model
|
||||
$this->dbTable = 'campus.tbl_contentchild';
|
||||
$this->pk = 'contentchild_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Children with titel, sorted by sort.
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getChilds($content_id, $sprache)
|
||||
{
|
||||
$query = '
|
||||
SELECT cc.contentchild_id, cc.child_content_id, cc.sort,
|
||||
(SELECT titel FROM campus.tbl_contentsprache
|
||||
WHERE content_id = cc.child_content_id AND sprache = ?
|
||||
ORDER BY version LIMIT 1) AS titel
|
||||
FROM campus.tbl_contentchild cc
|
||||
WHERE cc.content_id = ?
|
||||
ORDER BY cc.sort, cc.contentchild_id
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, [$sprache, $content_id]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parents of a content, with the title of each parent.
|
||||
* A content can hang under more than one parent, so this returns a list.
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getParents($content_id, $sprache)
|
||||
{
|
||||
$query = '
|
||||
SELECT cc.contentchild_id, cc.content_id, cc.sort,
|
||||
(SELECT titel FROM campus.tbl_contentsprache
|
||||
WHERE content_id = cc.content_id AND sprache = ?
|
||||
ORDER BY version LIMIT 1) AS titel
|
||||
FROM campus.tbl_contentchild cc
|
||||
WHERE cc.child_content_id = ?
|
||||
ORDER BY cc.content_id
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, [$sprache, $content_id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Children of a content as a reader may see them.
|
||||
*
|
||||
* Differs from getChilds() on purpose. getChilds() serves the admin and returns every
|
||||
* child with the lowest version. A reader needs the newest visible version, only active
|
||||
* contents, and no page that a group rule keeps from them.
|
||||
*
|
||||
* The group rule follows CmsLib::getContent(): a content without a row in
|
||||
* tbl_contentgruppe is open, a content with rows needs membership in one of them.
|
||||
*
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @param string $uid
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getChildsForReader($content_id, $sprache, $uid)
|
||||
{
|
||||
$query = '
|
||||
SELECT
|
||||
cc.child_content_id AS content_id,
|
||||
cc.sort,
|
||||
COALESCE(
|
||||
(SELECT cs.titel FROM campus.tbl_contentsprache cs
|
||||
WHERE cs.content_id = cc.child_content_id
|
||||
AND cs.sprache = ?
|
||||
AND cs.sichtbar
|
||||
AND LENGTH(cs.titel) > 0
|
||||
ORDER BY cs.version DESC NULLS LAST LIMIT 1),
|
||||
(SELECT cs.titel FROM campus.tbl_contentsprache cs
|
||||
WHERE cs.content_id = cc.child_content_id
|
||||
AND cs.sichtbar
|
||||
AND LENGTH(cs.titel) > 0
|
||||
ORDER BY cs.version DESC NULLS LAST LIMIT 1),
|
||||
c.beschreibung
|
||||
) AS titel
|
||||
FROM campus.tbl_contentchild cc
|
||||
JOIN campus.tbl_content c ON c.content_id = cc.child_content_id
|
||||
WHERE cc.content_id = ?
|
||||
AND c.aktiv
|
||||
-- Without a visible version the reader cannot open the page at all.
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM campus.tbl_contentsprache cs
|
||||
WHERE cs.content_id = cc.child_content_id AND cs.sichtbar
|
||||
)
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM campus.tbl_contentgruppe cg
|
||||
WHERE cg.content_id = cc.child_content_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM campus.tbl_contentgruppe cg
|
||||
JOIN public.vw_gruppen vg ON vg.gruppe_kurzbz = cg.gruppe_kurzbz
|
||||
WHERE cg.content_id = cc.child_content_id AND vg.uid = ?
|
||||
)
|
||||
)
|
||||
ORDER BY cc.sort, cc.contentchild_id
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, [$sprache, $content_id, $uid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest sort value. Returns 0 if no row exists.
|
||||
* @param int $content_id
|
||||
* @return stdClass success with int or error
|
||||
*/
|
||||
public function getMaxSort($content_id)
|
||||
{
|
||||
$query = '
|
||||
SELECT COALESCE(MAX(sort), 0) AS max_sort
|
||||
FROM campus.tbl_contentchild
|
||||
WHERE content_id = ?
|
||||
';
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, [$content_id]);
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
return success((int) getData($result)[0]->max_sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* All descendant content_ids recursively. Returns a flat array.
|
||||
* @param int $content_id
|
||||
* @return stdClass success with array of ints or error
|
||||
*/
|
||||
public function getAllChildIds($content_id)
|
||||
{
|
||||
$query = '
|
||||
WITH RECURSIVE childs(content_id, child_content_id) AS (
|
||||
SELECT content_id, child_content_id FROM campus.tbl_contentchild WHERE content_id = ?
|
||||
UNION ALL
|
||||
SELECT cc.content_id, cc.child_content_id
|
||||
FROM campus.tbl_contentchild cc, childs
|
||||
WHERE cc.content_id = childs.child_content_id)
|
||||
SELECT DISTINCT child_content_id FROM childs
|
||||
';
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, [$content_id]);
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
$ids = [];
|
||||
if (getData($result))
|
||||
{
|
||||
foreach (getData($result) as $row)
|
||||
$ids[] = (int) $row->child_content_id;
|
||||
}
|
||||
|
||||
return success($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap sort value with the neighbour. Direction is 'up' or 'down'.
|
||||
* @param int $contentchild_id
|
||||
* @param string $direction 'up' or 'down'
|
||||
* @return stdClass success or error
|
||||
*/
|
||||
public function swapSort($contentchild_id, $direction)
|
||||
{
|
||||
$currentResult = $this->load($contentchild_id);
|
||||
if (isError($currentResult))
|
||||
return $currentResult;
|
||||
$current = getData($currentResult)[0];
|
||||
|
||||
if ($direction === 'up')
|
||||
{
|
||||
$neighbourQuery = '
|
||||
SELECT contentchild_id, sort
|
||||
FROM campus.tbl_contentchild
|
||||
WHERE content_id = ? AND sort < ?
|
||||
ORDER BY sort DESC
|
||||
LIMIT 1
|
||||
';
|
||||
}
|
||||
else
|
||||
{
|
||||
$neighbourQuery = '
|
||||
SELECT contentchild_id, sort
|
||||
FROM campus.tbl_contentchild
|
||||
WHERE content_id = ? AND sort > ?
|
||||
ORDER BY sort ASC
|
||||
LIMIT 1
|
||||
';
|
||||
}
|
||||
|
||||
$neighbourResult = $this->execReadOnlyQuery($neighbourQuery, [$current->content_id, $current->sort]);
|
||||
if (isError($neighbourResult))
|
||||
return $neighbourResult;
|
||||
|
||||
$neighbourData = getData($neighbourResult);
|
||||
// A marker, not a message. The controller owns the wording, because a model has no
|
||||
// phrases. Returning a phrase key here put the key itself in front of the user.
|
||||
if (empty($neighbourData))
|
||||
return error(self::NO_NEIGHBOUR);
|
||||
|
||||
$neighbour = $neighbourData[0];
|
||||
|
||||
// Transaction wraps the swap; the legacy does not.
|
||||
$this->db->trans_start();
|
||||
|
||||
$this->execQuery(
|
||||
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
|
||||
[$neighbour->sort, $current->contentchild_id]
|
||||
);
|
||||
$this->execQuery(
|
||||
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
|
||||
[$current->sort, $neighbour->contentchild_id]
|
||||
);
|
||||
|
||||
$this->db->trans_complete();
|
||||
|
||||
if ($this->db->trans_status() === false)
|
||||
return error('Sort swap failed');
|
||||
|
||||
return success(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the whole order of one content in one transaction. Serves the drag and drop.
|
||||
* The list must hold every child of the content exactly once, or the rows left out
|
||||
* would keep a stale sort value.
|
||||
* @param int $content_id
|
||||
* @param array $contentchild_ids in the wanted order
|
||||
* @return stdClass success(true) or error
|
||||
*/
|
||||
public function setSortOrder($content_id, $contentchild_ids)
|
||||
{
|
||||
$currentResult = $this->execReadOnlyQuery(
|
||||
'SELECT contentchild_id FROM campus.tbl_contentchild WHERE content_id = ?',
|
||||
[$content_id]
|
||||
);
|
||||
if (isError($currentResult))
|
||||
return $currentResult;
|
||||
|
||||
$current = [];
|
||||
foreach ((array) getData($currentResult) as $row)
|
||||
$current[] = (int) $row->contentchild_id;
|
||||
|
||||
$wanted = array_map('intval', array_values($contentchild_ids));
|
||||
|
||||
$check = $wanted;
|
||||
sort($current);
|
||||
sort($check);
|
||||
if ($current !== $check)
|
||||
return error('Sort order does not match the children of the content');
|
||||
|
||||
$this->db->trans_start();
|
||||
|
||||
foreach ($wanted as $position => $contentchild_id)
|
||||
{
|
||||
$this->execQuery(
|
||||
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
|
||||
[$position + 1, $contentchild_id]
|
||||
);
|
||||
}
|
||||
|
||||
$this->db->trans_complete();
|
||||
|
||||
if ($this->db->trans_status() === false)
|
||||
return error('Sort order update failed');
|
||||
|
||||
return success(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ancestor of the given contents, with the distance to the content it belongs to.
|
||||
*
|
||||
* The tree shows a search result out of its place: a title like "Team" says nothing
|
||||
* about which unit it belongs to. This delivers the way down to it.
|
||||
*
|
||||
* A content can hang under more than one parent, so a level can hold more than one
|
||||
* ancestor. Every one of them comes back, with tiefe 1 for the direct parent. The
|
||||
* caller decides how to draw that.
|
||||
*
|
||||
* tiefe also ends a cycle: a content whose parent chain leads back to itself would
|
||||
* otherwise recurse forever.
|
||||
*
|
||||
* @param array $content_ids contents to walk up from
|
||||
* @param int $maxTiefe how many levels to climb
|
||||
* @param string $sprache language of the ancestor titles
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getAncestors($content_ids, $maxTiefe, $sprache)
|
||||
{
|
||||
if (empty($content_ids))
|
||||
return success([]);
|
||||
|
||||
$platzhalter = implode(', ', array_fill(0, count($content_ids), '?'));
|
||||
|
||||
// The aggregate and the title sit in the final select. The recursive term names
|
||||
// the working table once and carries no subquery, which is what Postgres allows.
|
||||
$query = '
|
||||
WITH RECURSIVE auf(start_id, content_id, tiefe) AS (
|
||||
SELECT cc.child_content_id, cc.content_id, 1
|
||||
FROM campus.tbl_contentchild cc
|
||||
WHERE cc.child_content_id IN (' . $platzhalter . ')
|
||||
UNION ALL
|
||||
SELECT auf.start_id, cc.content_id, auf.tiefe + 1
|
||||
FROM campus.tbl_contentchild cc
|
||||
JOIN auf ON cc.child_content_id = auf.content_id
|
||||
WHERE auf.tiefe < ?
|
||||
)
|
||||
SELECT auf.start_id, auf.content_id, MIN(auf.tiefe) AS tiefe,
|
||||
(SELECT titel FROM campus.tbl_contentsprache cs
|
||||
WHERE cs.content_id = auf.content_id AND cs.sprache = ?
|
||||
ORDER BY cs.version LIMIT 1) AS titel,
|
||||
-- The caller turns this into the entitled flag, so the path can offer a
|
||||
-- link only for an ancestor the editor may open.
|
||||
(SELECT c.oe_kurzbz FROM campus.tbl_content c
|
||||
WHERE c.content_id = auf.content_id) AS oe_kurzbz
|
||||
FROM auf
|
||||
GROUP BY auf.start_id, auf.content_id
|
||||
ORDER BY auf.start_id, MIN(auf.tiefe) DESC, auf.content_id
|
||||
';
|
||||
|
||||
$params = array_map('intval', array_values($content_ids));
|
||||
$params[] = (int) $maxTiefe;
|
||||
$params[] = $sprache;
|
||||
|
||||
return $this->execReadOnlyQuery($query, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a content from the child table, as parent and as child.
|
||||
* @param int $content_id
|
||||
* @return stdClass
|
||||
*/
|
||||
public function deleteByContent($content_id)
|
||||
{
|
||||
return $this->execQuery(
|
||||
'DELETE FROM campus.tbl_contentchild
|
||||
WHERE content_id = ? OR child_content_id = ?',
|
||||
[$content_id, $content_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ class Contentgruppe_model extends DB_Model
|
||||
parent::__construct();
|
||||
$this->dbTable = 'campus.tbl_contentgruppe';
|
||||
$this->pk = array('gruppe_kurzbz', 'content_id');
|
||||
// Composite key without a sequence, so insert() must skip insert_id() and its LASTVAL().
|
||||
$this->hasSequence = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +18,7 @@ class Contentgruppe_model extends DB_Model
|
||||
*
|
||||
* @param int $content_id
|
||||
*
|
||||
* @return stdClass success(true) wenn locked sonst success(false)
|
||||
* @return stdClass success(true) wenn eingeschraenkt sonst success(false)
|
||||
*/
|
||||
public function islocked($content_id)
|
||||
{
|
||||
@@ -32,7 +30,7 @@ class Contentgruppe_model extends DB_Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein User die Berechtigung fuer das Anzeigen des
|
||||
* Prueft ob ein User die Berechtigung fuer das Anzeigen des
|
||||
* Contents besitzt
|
||||
*
|
||||
* @param int $content_id ID des Contents
|
||||
@@ -58,89 +56,5 @@ class Contentgruppe_model extends DB_Model
|
||||
return $result;
|
||||
return success(!!getData($result));
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups assigned to a content, with bezeichnung.
|
||||
* @param int $content_id
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getGruppen($content_id)
|
||||
{
|
||||
$query = '
|
||||
SELECT cg.gruppe_kurzbz, g.bezeichnung
|
||||
FROM campus.tbl_contentgruppe cg
|
||||
JOIN public.tbl_gruppe g USING (gruppe_kurzbz)
|
||||
WHERE cg.content_id = ?
|
||||
ORDER BY cg.gruppe_kurzbz
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, [$content_id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* All selectable groups. Returns gruppe_kurzbz and bezeichnung.
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getAllGruppen()
|
||||
{
|
||||
// content_visible marks a group as selectable for content rights. Generated groups
|
||||
// carry FALSE. admin.php filters the same way via getgruppe(...,$content_visible=true).
|
||||
$query = '
|
||||
SELECT gruppe_kurzbz, bezeichnung
|
||||
FROM public.tbl_gruppe
|
||||
WHERE content_visible = TRUE
|
||||
ORDER BY gruppe_kurzbz
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups for multiple contents in one query. Returns content_id => [gruppe_kurzbz, ...].
|
||||
* @param array $contentIds array of content_id values
|
||||
* @return stdClass success with associative array or error
|
||||
*/
|
||||
public function getGruppenForContents($contentIds)
|
||||
{
|
||||
if (empty($contentIds))
|
||||
return success([]);
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($contentIds), '?'));
|
||||
$query = '
|
||||
SELECT content_id, gruppe_kurzbz
|
||||
FROM campus.tbl_contentgruppe
|
||||
WHERE content_id IN (' . $placeholders . ')
|
||||
ORDER BY content_id, gruppe_kurzbz
|
||||
';
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, array_values($contentIds));
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
$map = [];
|
||||
if (getData($result))
|
||||
{
|
||||
foreach (getData($result) as $row)
|
||||
{
|
||||
$id = (int) $row->content_id;
|
||||
if (!isset($map[$id]))
|
||||
$map[$id] = [];
|
||||
$map[$id][] = $row->gruppe_kurzbz;
|
||||
}
|
||||
}
|
||||
|
||||
return success($map);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @return stdClass
|
||||
*/
|
||||
public function deleteByContent($content_id)
|
||||
{
|
||||
return $this->execQuery(
|
||||
'DELETE FROM campus.tbl_contentgruppe WHERE content_id = ?',
|
||||
[$content_id]
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,76 +11,4 @@ class Contentlog_model extends DB_Model
|
||||
$this->dbTable = 'campus.tbl_contentlog';
|
||||
$this->pk = 'contentlog_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* The open lock entry of a user (ende IS NULL). Returns null if none exists.
|
||||
* Match the holder and take the newest, otherwise the age belongs to an abandoned entry.
|
||||
* @param int $contentsprache_id
|
||||
* @param string $uid holder of the lock
|
||||
* @return stdClass success with row or null, or error
|
||||
*/
|
||||
public function getOpenLock($contentsprache_id, $uid)
|
||||
{
|
||||
$query = '
|
||||
SELECT contentlog_id, uid, contentsprache_id, start
|
||||
FROM campus.tbl_contentlog
|
||||
WHERE contentsprache_id = ? AND uid = ? AND ende IS NULL
|
||||
ORDER BY start DESC
|
||||
LIMIT 1
|
||||
';
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, [$contentsprache_id, $uid]);
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
$data = getData($result);
|
||||
return success(empty($data) ? null : $data[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends every open lock entry of a version. Without a uid it ends the entry of every
|
||||
* holder, which is what a take-over and a forced release need.
|
||||
* @param int $contentsprache_id
|
||||
* @param string|null $uid
|
||||
* @return stdClass
|
||||
*/
|
||||
public function closeOpenEntries($contentsprache_id, $uid = null)
|
||||
{
|
||||
$query = 'UPDATE campus.tbl_contentlog SET ende = now()
|
||||
WHERE contentsprache_id = ? AND ende IS NULL';
|
||||
$params = [$contentsprache_id];
|
||||
|
||||
if ($uid !== null)
|
||||
{
|
||||
$query .= ' AND uid = ?';
|
||||
$params[] = $uid;
|
||||
}
|
||||
|
||||
return $this->execQuery($query, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $contentsprache_id
|
||||
* @return stdClass
|
||||
*/
|
||||
public function deleteByContentsprache($contentsprache_id)
|
||||
{
|
||||
return $this->execQuery(
|
||||
'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id = ?',
|
||||
[$contentsprache_id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @return stdClass
|
||||
*/
|
||||
public function deleteByContent($content_id)
|
||||
{
|
||||
return $this->execQuery(
|
||||
'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id IN
|
||||
(SELECT contentsprache_id FROM campus.tbl_contentsprache WHERE content_id = ?)',
|
||||
[$content_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,177 +38,5 @@ class Contentsprache_model extends DB_Model
|
||||
|
||||
return success(!!getData($result));
|
||||
}
|
||||
|
||||
/**
|
||||
* All versions of a language, ascending by version.
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getVersions($content_id, $sprache)
|
||||
{
|
||||
$query = '
|
||||
SELECT contentsprache_id, version, sichtbar, titel,
|
||||
insertamum, insertvon, updateamum, updatevon, gesperrt_uid
|
||||
FROM campus.tbl_contentsprache
|
||||
WHERE content_id = ? AND sprache = ?
|
||||
ORDER BY version ASC
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, [$content_id, $sprache]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct languages for a content. Returns a flat array of strings.
|
||||
* @param int $content_id
|
||||
* @return stdClass success with array of strings or error
|
||||
*/
|
||||
public function getLanguages($content_id)
|
||||
{
|
||||
$query = '
|
||||
SELECT DISTINCT sprache
|
||||
FROM campus.tbl_contentsprache
|
||||
WHERE content_id = ?
|
||||
ORDER BY sprache
|
||||
';
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, [$content_id]);
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
$languages = [];
|
||||
if (getData($result))
|
||||
{
|
||||
foreach (getData($result) as $row)
|
||||
$languages[] = $row->sprache;
|
||||
}
|
||||
|
||||
return success($languages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest version number. Returns 0 if no row exists.
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @return stdClass success with int or error
|
||||
*/
|
||||
public function getMaxVersion($content_id, $sprache)
|
||||
{
|
||||
$query = '
|
||||
SELECT COALESCE(MAX(version), 0) AS max_version
|
||||
FROM campus.tbl_contentsprache
|
||||
WHERE content_id = ? AND sprache = ?
|
||||
';
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, [$content_id, $sprache]);
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
return success((int) getData($result)[0]->max_version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row count for a content+language. Used for the delete guard.
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @return stdClass success with int or error
|
||||
*/
|
||||
public function getNumberOfVersions($content_id, $sprache)
|
||||
{
|
||||
$query = '
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM campus.tbl_contentsprache
|
||||
WHERE content_id = ? AND sprache = ?
|
||||
';
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, [$content_id, $sprache]);
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
return success((int) getData($result)[0]->cnt);
|
||||
}
|
||||
|
||||
/**
|
||||
* One version, all columns. If $version is empty, returns the highest version.
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @param int|null $version
|
||||
* @return stdClass success with single row or error
|
||||
*/
|
||||
public function getOne($content_id, $sprache, $version = null)
|
||||
{
|
||||
$params = [$content_id, $sprache];
|
||||
|
||||
$versionClause = '';
|
||||
if (!empty($version))
|
||||
{
|
||||
$versionClause = ' AND version = ?';
|
||||
$params[] = $version;
|
||||
}
|
||||
|
||||
$query = '
|
||||
SELECT * FROM campus.tbl_contentsprache
|
||||
WHERE content_id = ? AND sprache = ?' . $versionClause . '
|
||||
ORDER BY version DESC LIMIT 1
|
||||
';
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, $params);
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
$data = getData($result);
|
||||
if (empty($data))
|
||||
return error('Version not found');
|
||||
|
||||
return success($data[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the lock of a version. Without a uid it clears whoever holds it, which is
|
||||
* what a forced release needs.
|
||||
* @param int $contentsprache_id
|
||||
* @param string|null $uid
|
||||
* @return stdClass
|
||||
*/
|
||||
public function releaseLock($contentsprache_id, $uid = null)
|
||||
{
|
||||
$query = 'UPDATE campus.tbl_contentsprache SET gesperrt_uid = NULL
|
||||
WHERE contentsprache_id = ?';
|
||||
$params = [$contentsprache_id];
|
||||
|
||||
if ($uid !== null)
|
||||
{
|
||||
$query .= ' AND gesperrt_uid = ?';
|
||||
$params[] = $uid;
|
||||
}
|
||||
|
||||
return $this->execQuery($query, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @return stdClass
|
||||
*/
|
||||
public function deleteByContent($content_id)
|
||||
{
|
||||
return $this->execQuery(
|
||||
'DELETE FROM campus.tbl_contentsprache WHERE content_id = ?',
|
||||
[$content_id]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $content_id
|
||||
* @param string $sprache
|
||||
* @param int $version
|
||||
* @return stdClass
|
||||
*/
|
||||
public function deleteVersion($content_id, $sprache, $version)
|
||||
{
|
||||
return $this->execQuery(
|
||||
'DELETE FROM campus.tbl_contentsprache
|
||||
WHERE content_id = ? AND sprache = ? AND version = ?',
|
||||
[$content_id, $sprache, $version]
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -30,241 +30,4 @@ class Dms_model extends DB_Model
|
||||
|
||||
return $returnArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a DMS category and the access facts a listing needs.
|
||||
*
|
||||
* Mirrors the rule of cms/dms.php and dms.class.php at category level:
|
||||
* a category is locked when any category in its parent chain grants a group or
|
||||
* carries a berechtigung_kurzbz. The caller decides, this method only reports.
|
||||
*
|
||||
* @param string $kategorie_kurzbz
|
||||
* gesperrt and in_gruppe come back as 0 or 1, not as a boolean. A PostgreSQL boolean
|
||||
* reaches PHP as the string 'f' when the driver conversion does not run, and 'f' is
|
||||
* truthy. The int cast is correct in both cases.
|
||||
*
|
||||
* @param string $uid
|
||||
* @return object one row, or an empty result when the category does not exist
|
||||
*/
|
||||
public function getKategorieZugriff($kategorie_kurzbz, $uid)
|
||||
{
|
||||
// tiefe guards against a cyclic parent chain.
|
||||
$query = '
|
||||
WITH RECURSIVE chain(kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, tiefe) AS (
|
||||
SELECT kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, 1
|
||||
FROM campus.tbl_dms_kategorie
|
||||
WHERE kategorie_kurzbz = ?
|
||||
UNION ALL
|
||||
SELECT k.kategorie_kurzbz, k.parent_kategorie_kurzbz, k.berechtigung_kurzbz, c.tiefe + 1
|
||||
FROM campus.tbl_dms_kategorie k
|
||||
JOIN chain c ON k.kategorie_kurzbz = c.parent_kategorie_kurzbz
|
||||
WHERE c.tiefe < 32
|
||||
)
|
||||
SELECT
|
||||
kat.kategorie_kurzbz,
|
||||
kat.bezeichnung,
|
||||
kat.beschreibung,
|
||||
kat.berechtigung_kurzbz,
|
||||
(
|
||||
EXISTS (SELECT 1 FROM chain WHERE chain.berechtigung_kurzbz IS NOT NULL)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM campus.tbl_dms_kategorie_gruppe g
|
||||
JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz
|
||||
)
|
||||
)::int AS gesperrt,
|
||||
(EXISTS (
|
||||
SELECT 1
|
||||
FROM campus.tbl_dms_kategorie_gruppe g
|
||||
JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz
|
||||
JOIN public.tbl_benutzergruppe b ON b.gruppe_kurzbz = g.gruppe_kurzbz
|
||||
WHERE b.uid = ?
|
||||
))::int AS in_gruppe
|
||||
FROM campus.tbl_dms_kategorie kat
|
||||
WHERE kat.kategorie_kurzbz = ?
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz, $uid, $kategorie_kurzbz));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the newest version of every document in one DMS category.
|
||||
*
|
||||
* filename stays out of the result. The reader gets the document through
|
||||
* cms/dms.php?id=<dms_id>, never through the name on disk.
|
||||
*
|
||||
* @param string $kategorie_kurzbz
|
||||
* @return object
|
||||
*/
|
||||
public function getKategorieDokumente($kategorie_kurzbz)
|
||||
{
|
||||
$query = '
|
||||
SELECT
|
||||
d.dms_id,
|
||||
v.version,
|
||||
v.name,
|
||||
v.beschreibung,
|
||||
v.mimetype,
|
||||
COALESCE(v.updateamum, v.insertamum) AS geaendert
|
||||
FROM campus.tbl_dms d
|
||||
JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id
|
||||
WHERE d.kategorie_kurzbz = ?
|
||||
-- cis_suche marks a document as published to CIS readers. A category also
|
||||
-- holds working files, and those must not appear on a content page.
|
||||
AND v.cis_suche
|
||||
AND v.version = (
|
||||
SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id
|
||||
)
|
||||
-- A project document carries its own rule in dms.class.php and does not
|
||||
-- belong in a CMS category listing.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
|
||||
)
|
||||
ORDER BY LOWER(v.name)
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz));
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest version of the named documents, in no particular order.
|
||||
*
|
||||
* Unlike getKategorieDokumente() this does NOT filter on cis_suche. An editor who names
|
||||
* a dms_id has chosen that document on purpose, and the flag only marks a document for
|
||||
* the CIS search. The category rule still applies, and the caller enforces it.
|
||||
*
|
||||
* @param array $ids dms_id values
|
||||
* @return object
|
||||
*/
|
||||
public function getDokumenteByIds($ids)
|
||||
{
|
||||
if (empty($ids))
|
||||
return success(array());
|
||||
|
||||
$platzhalter = implode(', ', array_fill(0, count($ids), '?'));
|
||||
|
||||
$query = '
|
||||
SELECT
|
||||
d.dms_id,
|
||||
d.kategorie_kurzbz,
|
||||
v.version,
|
||||
v.name,
|
||||
v.beschreibung,
|
||||
v.mimetype,
|
||||
COALESCE(v.updateamum, v.insertamum) AS geaendert
|
||||
FROM campus.tbl_dms d
|
||||
JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id
|
||||
WHERE d.dms_id IN (' . $platzhalter . ')
|
||||
AND v.version = (
|
||||
SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
|
||||
)
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, array_values($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every document of one category, for the editor picker.
|
||||
*
|
||||
* Unlike getKategorieDokumente() this does NOT filter on cis_suche, because
|
||||
* dms-dokumente renders named documents regardless of that flag. The flag travels with
|
||||
* the row so the picker can mark a document that no reader would find by search.
|
||||
*
|
||||
* @param string $kategorie_kurzbz
|
||||
* @return object
|
||||
*/
|
||||
public function getKategorieDokumenteAlle($kategorie_kurzbz)
|
||||
{
|
||||
$query = '
|
||||
SELECT
|
||||
d.dms_id,
|
||||
v.name,
|
||||
v.cis_suche::int AS cis_suche
|
||||
FROM campus.tbl_dms d
|
||||
JOIN LATERAL (
|
||||
SELECT v.name, v.cis_suche
|
||||
FROM campus.tbl_dms_version v
|
||||
WHERE v.dms_id = d.dms_id
|
||||
ORDER BY v.version DESC
|
||||
LIMIT 1
|
||||
) v ON TRUE
|
||||
WHERE d.kategorie_kurzbz = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
|
||||
)
|
||||
ORDER BY LOWER(v.name)
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every DMS category with the number of documents it holds.
|
||||
* Used by the insert dialog of the CMS content component "dms-liste".
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
public function getKategorien()
|
||||
{
|
||||
$query = '
|
||||
SELECT
|
||||
k.kategorie_kurzbz,
|
||||
k.bezeichnung,
|
||||
k.oe_kurzbz,
|
||||
COALESCE(a.anzahl, 0) AS anzahl
|
||||
FROM campus.tbl_dms_kategorie k
|
||||
LEFT JOIN (
|
||||
SELECT d.kategorie_kurzbz, COUNT(*) AS anzahl
|
||||
FROM campus.tbl_dms d
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (dms_id) dms_id, cis_suche
|
||||
FROM campus.tbl_dms_version
|
||||
ORDER BY dms_id, version DESC
|
||||
) v ON v.dms_id = d.dms_id
|
||||
WHERE v.cis_suche
|
||||
GROUP BY d.kategorie_kurzbz
|
||||
) a ON a.kategorie_kurzbz = k.kategorie_kurzbz
|
||||
ORDER BY LOWER(COALESCE(k.bezeichnung, k.kategorie_kurzbz))
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* The category itself plus its sub categories, down to a depth.
|
||||
*
|
||||
* tiefe 0 returns only the category named. The depth bound also ends a cycle: a
|
||||
* category whose parent chain leads back to itself would otherwise recurse forever.
|
||||
*
|
||||
* The caller must still check the entitlement of every category it gets back. A sub
|
||||
* category carries its own group rule and may be closed while the parent is open.
|
||||
*
|
||||
* @param string $kategorie_kurzbz root of the walk
|
||||
* @param int $tiefe how many levels below the root to include
|
||||
* @return object success with array of rows or error
|
||||
*/
|
||||
public function getMitUnterkategorien($kategorie_kurzbz, $tiefe)
|
||||
{
|
||||
$query = '
|
||||
WITH RECURSIVE unter(kategorie_kurzbz, tiefe) AS (
|
||||
SELECT kategorie_kurzbz, 0
|
||||
FROM campus.tbl_dms_kategorie
|
||||
WHERE kategorie_kurzbz = ?
|
||||
UNION ALL
|
||||
SELECT k.kategorie_kurzbz, unter.tiefe + 1
|
||||
FROM campus.tbl_dms_kategorie k
|
||||
JOIN unter ON k.parent_kategorie_kurzbz = unter.kategorie_kurzbz
|
||||
WHERE unter.tiefe < ?
|
||||
)
|
||||
SELECT kategorie_kurzbz, MIN(tiefe) AS tiefe
|
||||
FROM unter
|
||||
GROUP BY kategorie_kurzbz
|
||||
ORDER BY MIN(tiefe), kategorie_kurzbz
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, [$kategorie_kurzbz, (int) $tiefe]);
|
||||
}
|
||||
}
|
||||
@@ -11,30 +11,4 @@ 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,37 +259,4 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,13 +292,29 @@ class Studiensemester_model extends DB_Model
|
||||
COALESCE(
|
||||
(SELECT studiensemester_kurzbz FROM semester_interval_startdate),
|
||||
(SELECT studiensemester_kurzbz FROM nearest_semester_interval_startdate)
|
||||
) AS studiensemester_kurzbz
|
||||
) AS studiensemester_kurzbz,
|
||||
COALESCE(
|
||||
(SELECT start FROM semester_interval_startdate),
|
||||
(SELECT start FROM nearest_semester_interval_startdate)
|
||||
) as start,
|
||||
COALESCE(
|
||||
(SELECT ende FROM semester_interval_startdate),
|
||||
(SELECT ende FROM nearest_semester_interval_startdate)
|
||||
) as ende
|
||||
UNION
|
||||
SELECT
|
||||
COALESCE(
|
||||
(SELECT studiensemester_kurzbz FROM semester_interval_enddate),
|
||||
(SELECT studiensemester_kurzbz FROM nearest_semester_interval_enddate)
|
||||
) AS studiensemester_kurzbz
|
||||
) AS studiensemester_kurzbz,
|
||||
COALESCE(
|
||||
(SELECT start FROM semester_interval_enddate),
|
||||
(SELECT start FROM nearest_semester_interval_enddate)
|
||||
) as start,
|
||||
COALESCE(
|
||||
(SELECT ende FROM semester_interval_enddate),
|
||||
(SELECT ende FROM nearest_semester_interval_enddate)
|
||||
) as ende
|
||||
|
||||
EOSQL;
|
||||
|
||||
|
||||
@@ -243,58 +243,6 @@ class Person_model extends DB_Model
|
||||
* @param $uid
|
||||
* @return array
|
||||
*/
|
||||
/**
|
||||
* Contact data of the named users, for the CMS content components.
|
||||
*
|
||||
* The joins follow Benutzerfunktion_model::getBenutzerFunktionenDetailed() and the view
|
||||
* Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the same everywhere.
|
||||
*
|
||||
* foto_sperre is honoured. A person who blocked their photo never gets one on a public
|
||||
* content page, no matter what the page asks for.
|
||||
*
|
||||
* @param array $uids
|
||||
* @param boolean $mitFoto photos are large, so they are read only on request
|
||||
* @return object
|
||||
*/
|
||||
public function getCmsKontakt($uids, $mitFoto = false)
|
||||
{
|
||||
if (empty($uids))
|
||||
return success(array());
|
||||
|
||||
$platzhalter = implode(', ', array_fill(0, count($uids), '?'));
|
||||
$fotoSpalte = $mitFoto
|
||||
? 'CASE WHEN p.foto_sperre THEN NULL ELSE p.foto END'
|
||||
: 'NULL';
|
||||
|
||||
$query = '
|
||||
SELECT
|
||||
b.uid,
|
||||
b.alias,
|
||||
p.vorname,
|
||||
p.nachname,
|
||||
p.titelpre,
|
||||
p.titelpost,
|
||||
m.telefonklappe,
|
||||
k.kontakt,
|
||||
o.planbezeichnung,
|
||||
' . $fotoSpalte . ' AS foto
|
||||
FROM public.tbl_benutzer b
|
||||
JOIN public.tbl_person p ON p.person_id = b.person_id
|
||||
LEFT JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid
|
||||
LEFT JOIN public.tbl_kontakt k
|
||||
ON k.standort_id = m.standort_id AND k.kontakttyp = ?
|
||||
LEFT JOIN public.tbl_ort o ON o.ort_kurzbz = m.ort_kurzbz
|
||||
WHERE b.uid IN (' . $platzhalter . ')
|
||||
AND b.aktiv
|
||||
';
|
||||
|
||||
$parameter = array('telefon');
|
||||
foreach ($uids as $uid)
|
||||
$parameter[] = $uid;
|
||||
|
||||
return $this->execReadOnlyQuery($query, $parameter);
|
||||
}
|
||||
|
||||
public function getByUid($uid)
|
||||
{
|
||||
$this->addSelect('vorname, nachname, gebdatum, person_id, bpk, matr_nr, foto');
|
||||
|
||||
@@ -98,31 +98,6 @@ class Mitarbeiter_model extends DB_Model
|
||||
return $this->execQuery($qry, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active staff, names only, for a picker.
|
||||
*
|
||||
* getPersonal() returns tbl_person.* including svnr, bpk and
|
||||
* gebdatum, which is not needed in a CMS endpoint.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
public function getAktivesPersonalNamen()
|
||||
{
|
||||
$query = '
|
||||
SELECT
|
||||
b.uid,
|
||||
p.vorname,
|
||||
p.nachname
|
||||
FROM public.tbl_benutzer b
|
||||
JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid
|
||||
JOIN public.tbl_person p ON p.person_id = b.person_id
|
||||
WHERE b.aktiv
|
||||
ORDER BY LOWER(p.nachname), LOWER(p.vorname)
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* gibt Personen mit Übersicht von Vertragsdaten aus
|
||||
*
|
||||
|
||||
@@ -12,72 +12,4 @@ class Webservicelog_model extends DB_Model
|
||||
$this->dbTable = 'system.tbl_webservicelog';
|
||||
$this->pk = 'webservicelog_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* The most viewed contents since a date, with the title of each one.
|
||||
* request_id is a varchar, so the join casts the content_id to text. A cast of
|
||||
* request_id to a number would fail on a row that holds no number.
|
||||
* The join also drops a log row whose content is deleted.
|
||||
* @param string|null $since timestamp in the form Y-m-d H:i:s, null for the whole log
|
||||
* @param string $sprache language of the title
|
||||
* @param int $limit length of the ranking
|
||||
* @return stdClass success with array of rows or error
|
||||
*/
|
||||
public function getContentClickCounts($since, $sprache, $limit)
|
||||
{
|
||||
$params = [$sprache, 'content'];
|
||||
$sinceCondition = '';
|
||||
|
||||
if ($since !== null)
|
||||
{
|
||||
$sinceCondition = ' AND w.execute_time >= ?';
|
||||
$params[] = $since;
|
||||
}
|
||||
|
||||
$params[] = $limit;
|
||||
|
||||
$query = '
|
||||
SELECT c.content_id, COUNT(*) AS hits,
|
||||
(SELECT titel FROM campus.tbl_contentsprache
|
||||
WHERE content_id = c.content_id AND sprache = ?
|
||||
ORDER BY version LIMIT 1) AS titel
|
||||
FROM system.tbl_webservicelog w
|
||||
JOIN campus.tbl_content c ON c.content_id::text = w.request_id
|
||||
WHERE w.webservicetyp_kurzbz = ?' . $sinceCondition . '
|
||||
GROUP BY c.content_id
|
||||
ORDER BY COUNT(*) DESC, c.content_id
|
||||
LIMIT ?
|
||||
';
|
||||
|
||||
return $this->execReadOnlyQuery($query, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Views of one content since a date. A content outside the ranking still needs its
|
||||
* own number.
|
||||
* @param int $content_id
|
||||
* @param string|null $since timestamp in the form Y-m-d H:i:s, null for the whole log
|
||||
* @return stdClass success with int or error
|
||||
*/
|
||||
public function getContentClickCount($content_id, $since)
|
||||
{
|
||||
$query = '
|
||||
SELECT COUNT(*) AS hits
|
||||
FROM system.tbl_webservicelog
|
||||
WHERE webservicetyp_kurzbz = ? AND request_id = ?
|
||||
';
|
||||
$params = ['content', (string) $content_id];
|
||||
|
||||
if ($since !== null)
|
||||
{
|
||||
$query .= ' AND execute_time >= ?';
|
||||
$params[] = $since;
|
||||
}
|
||||
|
||||
$result = $this->execReadOnlyQuery($query, $params);
|
||||
if (isError($result))
|
||||
return $result;
|
||||
|
||||
return success((int) getData($result)[0]->hits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ $includesArray = array(
|
||||
'public/css/components/FormUnderline.css',
|
||||
'public/css/components/abgabetool/abgabe.css',
|
||||
'public/css/Cis4/Cms.css',
|
||||
'public/css/Cis4/Contentcomponents.css',
|
||||
'public/css/Cis4/Studium.css'
|
||||
),
|
||||
'customJSs' => array(
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
$includesArray = array(
|
||||
'title' => 'CMS',
|
||||
'axios027' => true,
|
||||
'bootstrap5' => true,
|
||||
'fontawesome6' => true,
|
||||
'vue3' => true,
|
||||
'primevue3' => true,
|
||||
'tabulator6' => true,
|
||||
'tinymce5' => true,
|
||||
'phrases' => array('global', 'ui', 'cms'),
|
||||
'customCSSs' => [
|
||||
'public/css/components/verticalsplit.css',
|
||||
'public/css/components/horizontalsplit.css',
|
||||
'public/css/Cms.css'
|
||||
],
|
||||
'customJSs' => [
|
||||
'vendor/npm-asset/primevue/tree/tree.min.js',
|
||||
'vendor/npm-asset/primevue/checkbox/checkbox.min.js',
|
||||
'vendor/npm-asset/primevue/textarea/textarea.min.js',
|
||||
'vendor/moment/luxonjs/luxon.min.js'
|
||||
],
|
||||
'customJSModules' => ['public/js/apps/Cms.js']
|
||||
);
|
||||
$this->load->view('templates/FHC-Header', $includesArray);
|
||||
?>
|
||||
<div id="main">
|
||||
<router-view
|
||||
cms-root="<?= site_url('cms'); ?>"
|
||||
auth-uid="<?= getAuthUID(); ?>"
|
||||
default-language="<?= defined('DEFAULT_LANGUAGE') ? DEFAULT_LANGUAGE : 'German'; ?>"
|
||||
<?= !empty($clickstats) ? 'clickstats' : ''; ?>
|
||||
></router-view>
|
||||
</div>
|
||||
<?php $this->load->view('templates/FHC-Footer', $includesArray); ?>
|
||||
@@ -83,40 +83,12 @@ if (! $rechte->isberechtigt('basis/dms', null, 's', null))
|
||||
return confirm('Möchten Sie das File wirklich löschen?');
|
||||
}
|
||||
|
||||
/*
|
||||
* CIS4 bridge, ticket 78489.
|
||||
*
|
||||
* The TinyMCE 5 editor of the new CMS admin cannot receive a selection through the
|
||||
* TinyMCE 3 API below, so this window posts the URL back to its opener instead.
|
||||
*
|
||||
* message: { typ: 'fhc-dms-selection', url: 'dms.php?id=<dms_id>' }
|
||||
* target: window.opener, origin checked on both sides
|
||||
*
|
||||
* The path is chosen by window.name, which the opener sets and which survives the
|
||||
* internal navigation of this browser.
|
||||
*
|
||||
* Receiving side: public/js/components/Cms/Form/FieldWysiwyg.js
|
||||
*/
|
||||
var FileBrowserDialog=
|
||||
{
|
||||
init: function(){
|
||||
},
|
||||
mySubmit : function (id) {
|
||||
var URL = "dms.php?id="+id;
|
||||
|
||||
// CIS4 path: see the comment block above this function. Do not test for
|
||||
// tinyMCEPopup, this page loads tiny_mce_popup.js itself and always defines it.
|
||||
if (window.name === 'DMS_CIS4' && window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage({ typ: 'fhc-dms-selection', url: URL }, window.location.origin);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof tinyMCEPopup === 'undefined') {
|
||||
alert('The calling window is no longer available.');
|
||||
return;
|
||||
}
|
||||
|
||||
var win = tinyMCEPopup.getWindowArg("window");
|
||||
|
||||
// insert information now
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
* Manuela Thamer <[email protected]>
|
||||
*/
|
||||
require_once(dirname(__FILE__).'/basis_db.class.php');
|
||||
require_once(dirname(__FILE__).'/mitarbeiter.class.php');
|
||||
|
||||
class entwicklungsteam extends basis_db
|
||||
{
|
||||
@@ -43,7 +44,6 @@ class entwicklungsteam extends basis_db
|
||||
public $ext_id;
|
||||
|
||||
public $besqual;
|
||||
public $studiengang_kz_old;
|
||||
|
||||
/**
|
||||
* Konstruktor
|
||||
@@ -74,7 +74,7 @@ class entwicklungsteam extends basis_db
|
||||
$qry = "SELECT * FROM bis.tbl_entwicklungsteam JOIN bis.tbl_besqual USING(besqualcode)
|
||||
WHERE entwicklungsteam_id=".$this->db_add_param($entwicklungsteam_id);
|
||||
|
||||
$qry.=";";
|
||||
$qry.=";";
|
||||
|
||||
if($this->db_query($qry))
|
||||
{
|
||||
@@ -147,6 +147,11 @@ class entwicklungsteam extends basis_db
|
||||
$this->errormsg = 'Es muss ein Mitarbeiter angegeben werden';
|
||||
return false;
|
||||
}
|
||||
if(!$this->checkIfExistingUid($this->mitarbeiter_uid))
|
||||
{
|
||||
$this->errormsg = 'Bitte einen Eintrag aus dem Dropdown Personensuche auswählen!';
|
||||
return false;
|
||||
}
|
||||
if($this->studiengang_kz=='')
|
||||
{
|
||||
$this->errormsg = 'Es muss ein Studiengang angegeben werden';
|
||||
@@ -162,6 +167,26 @@ class entwicklungsteam extends basis_db
|
||||
$this->errormsg = 'Endedatum darf nicht vor Anfangsdatum liegen';
|
||||
return false;
|
||||
}
|
||||
|
||||
if($this->beginn != '')
|
||||
{
|
||||
$beginnParts = explode('-', $this->beginn);
|
||||
if (!checkdate((int)$beginnParts[1], (int)$beginnParts[2], (int)$beginnParts[0])) {
|
||||
$this->errormsg = 'Anfangsdatum ist ungültig';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if($this->ende != '')
|
||||
{
|
||||
$endeParts = explode('-', $this->ende);
|
||||
if (!checkdate((int)$endeParts[1], (int)$endeParts[2], (int)$endeParts[0]))
|
||||
{
|
||||
$this->errormsg = 'Endedatum ist ungültig';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -183,30 +208,28 @@ class entwicklungsteam extends basis_db
|
||||
//Neuen Datensatz anlegen
|
||||
$qry = "INSERT INTO bis.tbl_entwicklungsteam (mitarbeiter_uid, studiengang_kz, besqualcode, beginn, ende,
|
||||
updateamum, updatevon, insertamum, insertvon) VALUES (".
|
||||
$this->db_add_param($this->mitarbeiter_uid).', '.
|
||||
$this->db_add_param($this->studiengang_kz, FHC_INTEGER).', '.
|
||||
$this->db_add_param($this->besqualcode, FHC_INTEGER).', '.
|
||||
$this->db_add_param($this->beginn).', '.
|
||||
$this->db_add_param($this->ende).', '.
|
||||
$this->db_add_param($this->updateamum).', '.
|
||||
$this->db_add_param($this->updatevon).', '.
|
||||
$this->db_add_param($this->insertamum).', '.
|
||||
$this->db_add_param($this->insertvon).');';
|
||||
$this->db_add_param($this->mitarbeiter_uid).', '.
|
||||
$this->db_add_param($this->studiengang_kz, FHC_INTEGER).', '.
|
||||
$this->db_add_param($this->besqualcode, FHC_INTEGER).', '.
|
||||
$this->db_add_param($this->beginn).', '.
|
||||
$this->db_add_param($this->ende).', '.
|
||||
$this->db_add_param($this->updateamum).', '.
|
||||
$this->db_add_param($this->updatevon).', '.
|
||||
$this->db_add_param($this->insertamum).', '.
|
||||
$this->db_add_param($this->insertvon).');';
|
||||
}
|
||||
else
|
||||
{
|
||||
if($this->studiengang_kz_old=='')
|
||||
$this->studiengang_kz_old = $this->studiengang_kz;
|
||||
|
||||
//Bestehenden Datensatz aktualisieren
|
||||
$qry= "UPDATE bis.tbl_entwicklungsteam SET".
|
||||
" besqualcode=".$this->db_add_param($this->besqualcode, FHC_INTEGER).",".
|
||||
" beginn=".$this->db_add_param($this->beginn).",".
|
||||
" studiengang_kz=".$this->db_add_param($this->studiengang_kz, FHC_INTEGER).",".
|
||||
" ende=".$this->db_add_param($this->ende).",".
|
||||
" updateamum=".$this->db_add_param($this->updateamum).",".
|
||||
" updatevon=".$this->db_add_param($this->updatevon).
|
||||
" WHERE entwicklungsteam_id=".$this->db_add_param($this->entwicklungsteam_id).";";
|
||||
" besqualcode=".$this->db_add_param($this->besqualcode, FHC_INTEGER).",".
|
||||
" beginn=".$this->db_add_param($this->beginn).",".
|
||||
" studiengang_kz=".$this->db_add_param($this->studiengang_kz, FHC_INTEGER).",".
|
||||
" ende=".$this->db_add_param($this->ende).",".
|
||||
" mitarbeiter_uid=".$this->db_add_param($this->mitarbeiter_uid).",".
|
||||
" updateamum=".$this->db_add_param($this->updateamum).",".
|
||||
" updatevon=".$this->db_add_param($this->updatevon).
|
||||
" WHERE entwicklungsteam_id=".$this->db_add_param($this->entwicklungsteam_id).";";
|
||||
}
|
||||
|
||||
if($this->db_query($qry))
|
||||
@@ -234,7 +257,7 @@ class entwicklungsteam extends basis_db
|
||||
if($studiengang_kz!=null)
|
||||
$qry.=" AND studiengang_kz=".$this->db_add_param($studiengang_kz);
|
||||
|
||||
$qry.=";";
|
||||
$qry.=";";
|
||||
|
||||
if($this->db_query($qry))
|
||||
{
|
||||
@@ -316,7 +339,7 @@ class entwicklungsteam extends basis_db
|
||||
|
||||
if ($sort != null)
|
||||
{
|
||||
$qry .= " ORDER BY ".$sort;
|
||||
$qry .= " ORDER BY ".$sort;
|
||||
}
|
||||
|
||||
$qry .= ";";
|
||||
@@ -364,7 +387,7 @@ class entwicklungsteam extends basis_db
|
||||
$bismeldung_jahr = $datetime->format('Y');
|
||||
|
||||
//laden des Datensatzes
|
||||
$qry = "SELECT tbl_entwicklungsteam.*, tbl_besqual.*, tbl_studiengang.studiengang_kz, tbl_studiengang.melderelevant
|
||||
$qry = "SELECT tbl_entwicklungsteam.*, tbl_besqual.*, tbl_studiengang.studiengang_kz, tbl_studiengang.melderelevant
|
||||
FROM bis.tbl_entwicklungsteam
|
||||
JOIN bis.tbl_besqual USING(besqualcode)
|
||||
JOIN public.tbl_studiengang USING(studiengang_kz)
|
||||
@@ -375,7 +398,7 @@ class entwicklungsteam extends basis_db
|
||||
if($studiengang_kz!=null)
|
||||
$qry.=" AND studiengang_kz=".$this->db_add_param($studiengang_kz);
|
||||
|
||||
$qry.=";";
|
||||
$qry.=";";
|
||||
|
||||
if($this->db_query($qry))
|
||||
{
|
||||
@@ -407,5 +430,20 @@ class entwicklungsteam extends basis_db
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Überprüft, ob die übergebene Uid in der Tabelle tbl_mitarbeiter vorhanden ist
|
||||
* @param String $uid UID des Mitarbeiters
|
||||
* @return true wenn vorhanden, false wenn nicht
|
||||
*/
|
||||
private function checkIfExistingUid($uid)
|
||||
{
|
||||
$mitarbeiter = new mitarbeiter();
|
||||
|
||||
if(!$mitarbeiter->checkIfExistingUid($uid))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -1995,5 +1995,23 @@ class mitarbeiter extends benutzer
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft ob eine existierende ma_uid übergeben wurde
|
||||
* @param String $mitarbeiter_uid mitarbeiter_uid
|
||||
* @return bool True wenn mitarbeiter_uid vorhanden sonst false.
|
||||
*/
|
||||
public function checkIfExistingUid($mitarbeiter_uid)
|
||||
{
|
||||
$qry = "Select 1 from public.tbl_mitarbeiter where mitarbeiter_uid =".$this->db_add_param($mitarbeiter_uid);
|
||||
|
||||
if ($result = $this->db_query($qry))
|
||||
{
|
||||
if ($this->db_num_rows($result) > 0)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -57,6 +57,7 @@ class studiengang extends basis_db
|
||||
public $foerderrelevant; // boolean
|
||||
public $standort_code; // integer
|
||||
public $melde_studiengang_kz; // varchar(32)
|
||||
public $account_creation; // boolean
|
||||
|
||||
public $kuerzel; // = typ + kurzbz (Bsp: BBE)
|
||||
public $kuerzel_arr = array(); // Array mit allen Kurzeln Index=studiengangs_kz
|
||||
@@ -76,6 +77,8 @@ class studiengang extends basis_db
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->account_creation = true; // default value
|
||||
|
||||
if(!is_null($studiengang_kz))
|
||||
$this->load($studiengang_kz);
|
||||
}
|
||||
@@ -136,6 +139,7 @@ class studiengang extends basis_db
|
||||
$this->foerderrelevant = $this->db_parse_bool($row->foerderrelevant);
|
||||
$this->standort_code = $row->standort_code;
|
||||
$this->melde_studiengang_kz = $row->melde_studiengang_kz;
|
||||
$this->account_creation = $this->db_parse_bool($row->account_creation);
|
||||
|
||||
$this->bezeichnung_arr['German'] = $this->bezeichnung;
|
||||
$this->bezeichnung_arr['English'] = $this->english;
|
||||
@@ -213,6 +217,7 @@ class studiengang extends basis_db
|
||||
$stg_obj->foerderrelevant = $this->db_parse_bool($row->foerderrelevant);
|
||||
$stg_obj->standort_code = $row->standort_code;
|
||||
$stg_obj->melde_studiengang_kz = $row->melde_studiengang_kz;
|
||||
$stg_obj->account_creation = $this->db_parse_bool($row->account_creation);
|
||||
|
||||
$stg_obj->bezeichnung_arr['German'] = $row->bezeichnung;
|
||||
$stg_obj->bezeichnung_arr['English'] = $row->english;
|
||||
@@ -435,6 +440,7 @@ class studiengang extends basis_db
|
||||
$stg_obj->foerderrelevant = $this->db_parse_bool($row->foerderrelevant);
|
||||
$stg_obj->standort_code = $row->standort_code;
|
||||
$stg_obj->melde_studiengang_kz = $row->melde_studiengang_kz;
|
||||
$stg_obj->account_creation = $this->db_parse_bool($row->account_creation);
|
||||
|
||||
$stg_obj->bezeichnung_arr['German'] = $row->bezeichnung;
|
||||
$stg_obj->bezeichnung_arr['English'] = $row->english;
|
||||
@@ -510,7 +516,7 @@ class studiengang extends basis_db
|
||||
typ, farbe, email, telefon, max_verband, max_semester, max_gruppe, erhalter_kz, bescheid, bescheidbgbl1,
|
||||
bescheidbgbl2, bescheidgz, bescheidvom, titelbescheidvom, aktiv, onlinebewerbung, orgform_kurzbz, zusatzinfo_html,
|
||||
oe_kurzbz, moodle, sprache, testtool_sprachwahl, studienplaetze, lgartcode, mischform,projektarbeit_note_anzeige,
|
||||
melderelevant, foerderrelevant, standort_code, melde_studiengang_kz) VALUES ('.
|
||||
melderelevant, foerderrelevant, standort_code, melde_studiengang_kz, account_creation) VALUES ('.
|
||||
$this->db_add_param($this->studiengang_kz, FHC_INTEGER).', '.
|
||||
$this->db_add_param($this->kurzbz).', '.
|
||||
$this->db_add_param($this->kurzbzlang).', '.
|
||||
@@ -545,7 +551,8 @@ class studiengang extends basis_db
|
||||
$this->db_add_param($this->melderelevant, FHC_BOOLEAN).','.
|
||||
$this->db_add_param($this->foerderrelevant, FHC_BOOLEAN).','.
|
||||
$this->db_add_param($this->standort_code).','.
|
||||
$this->db_add_param($this->melde_studiengang_kz).');';
|
||||
$this->db_add_param($this->melde_studiengang_kz).','.
|
||||
$this->db_add_param($this->account_creation, FHC_BOOLEAN).');';
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -586,7 +593,8 @@ class studiengang extends basis_db
|
||||
'melderelevant='.$this->db_add_param($this->melderelevant, FHC_BOOLEAN).', '.
|
||||
'foerderrelevant='.$this->db_add_param($this->foerderrelevant, FHC_BOOLEAN).', '.
|
||||
'standort_code='.$this->db_add_param($this->standort_code).', '.
|
||||
'melde_studiengang_kz='.$this->db_add_param($this->melde_studiengang_kz).' '.
|
||||
'melde_studiengang_kz='.$this->db_add_param($this->melde_studiengang_kz).', '.
|
||||
'account_creation='.$this->db_add_param($this->account_creation, FHC_BOOLEAN).' '.
|
||||
'WHERE studiengang_kz='.$this->db_add_param($this->studiengang_kz, FHC_INTEGER, false).';';
|
||||
}
|
||||
|
||||
@@ -627,6 +635,27 @@ class studiengang extends basis_db
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the IDAM flag account_creation
|
||||
*/
|
||||
public function toggleAccountCreation($studiengang_kz)
|
||||
{
|
||||
if( !is_numeric($studiengang_kz))
|
||||
{
|
||||
$this->errormsg = 'Studiengang_kz muss eine gueltige Zahl sein';
|
||||
return false;
|
||||
}
|
||||
|
||||
$qry = 'UPDATE public.tbl_studiengang SET account_creation = NOT account_creation WHERE studiengang_kz = '.$this->db_add_param($studiengang_kz, FHC_INTEGER);
|
||||
if (!$this->db_query($qry))
|
||||
{
|
||||
$this->errormsg = 'Fehler beim Speichern des Datensatzes';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die UIDs der Studiengangsleiter
|
||||
*
|
||||
@@ -713,6 +742,7 @@ class studiengang extends basis_db
|
||||
$this->foerderrelevant = $this->db_parse_bool($row->foerderrelevant);
|
||||
$this->standort_code = $row->standort_code;
|
||||
$this->melde_studiengang_kz = $row->melde_studiengang_kz;
|
||||
$this->account_creation = $this->db_parse_bool($row->account_creation);
|
||||
$this->projektarbeit_note_anzeige = $this->db_parse_bool($row->projektarbeit_note_anzeige);
|
||||
|
||||
$this->bezeichnung_arr['German'] = $this->bezeichnung;
|
||||
@@ -857,6 +887,7 @@ class studiengang extends basis_db
|
||||
$obj->foerderrelevant = $this->db_parse_bool($row->foerderrelevant);
|
||||
$obj->standort_code = $row->standort_code;
|
||||
$obj->melde_studiengang_kz = $row->melde_studiengang_kz;
|
||||
$obj->account_creation = $this->db_parse_bool($row->account_creation);
|
||||
$obj->aktiv = $this->db_parse_bool($row->aktiv);
|
||||
|
||||
$this->result[] = $obj;
|
||||
@@ -1038,6 +1069,7 @@ class studiengang extends basis_db
|
||||
$obj->foerderrelevant = $this->db_parse_bool($row->foerderrelevant);
|
||||
$obj->standort_code = $row->standort_code;
|
||||
$obj->melde_studiengang_kz = $row->melde_studiengang_kz;
|
||||
$obj->account_creation = $this->db_parse_bool($row->account_creation);
|
||||
|
||||
$obj->bezeichnung_arr['German'] = $obj->bezeichnung;
|
||||
$obj->bezeichnung_arr['English'] = $obj->english;
|
||||
@@ -1176,6 +1208,7 @@ class studiengang extends basis_db
|
||||
$obj->foerderrelevant = $this->db_parse_bool($row->foerderrelevant);
|
||||
$obj->standort_code = $row->standort_code;
|
||||
$obj->melde_studiengang_kz = $row->melde_studiengang_kz;
|
||||
$obj->account_creation = $this->db_parse_bool($row->account_creation);
|
||||
|
||||
$obj->bezeichnung_arr['German'] = $obj->bezeichnung;
|
||||
$obj->bezeichnung_arr['English'] = $obj->english;
|
||||
|
||||
+17
-4
@@ -840,11 +840,8 @@ html {
|
||||
color: var(--fhc-light);
|
||||
}
|
||||
|
||||
/* the bootstrap opacity 0.5 reaches 2.9:1 on the blue header, 0.75 reaches 4.5:1 (WCAG 1.4.11) */
|
||||
#cis-main .modal-header .btn-close{
|
||||
filter: invert(1);
|
||||
--bs-btn-close-opacity: 0.75;
|
||||
--bs-btn-close-hover-opacity: 1;
|
||||
}
|
||||
|
||||
#cis-main .modal-footer {
|
||||
@@ -856,4 +853,20 @@ html {
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.cis-main-menu {
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.cis-main-menu ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cis-main-menu ul li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
/* Contentcomponents in CIS4 content.
|
||||
*
|
||||
* A contentcomponent renders inside its marker div, below #fhc-cms-content. The
|
||||
* components themselves carry Bootstrap utility classes only, so without this file they
|
||||
* read as unstyled Bootstrap.
|
||||
*
|
||||
* The class names are the contract with the components. A root class follows the pattern
|
||||
* fhc-contentcomponent-<name> and is set in the template of the component.
|
||||
*
|
||||
* Colours come from the theme. public/css/theme/default.css declares the semantic
|
||||
* variables under :root.light and :root.dark, never on bare :root, so every var() below
|
||||
* carries a fallback. The fallback repeats the light value.
|
||||
*
|
||||
* Two themes are covered: light and dark. purple takes the light values.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--fhc-cc-muted: #6c757d;
|
||||
--fhc-cc-accent: var(--fhc-primary, #006095);
|
||||
--fhc-cc-surface: var(--fhc-secondary, #f6f6f6);
|
||||
--fhc-cc-hover: var(--fhc-tertiary, #e9ecef);
|
||||
--fhc-cc-border: var(--fhc-border, #dee2e6);
|
||||
--fhc-cc-radius: 0.3rem;
|
||||
}
|
||||
|
||||
/* Two values need a step of their own. #006095 and #6c757d both fall under 3:1 on the
|
||||
dark background, which is too little even for secondary text and for an icon. */
|
||||
:root.dark {
|
||||
--fhc-cc-muted: var(--fhc-white-90, #aeb3b8);
|
||||
--fhc-cc-accent: var(--fhc-blue-40, #61aed8);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
/* The marker */
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
|
||||
/* The marker stays in the DOM and wraps the component. scan.js empties it, Teleport
|
||||
renders the component root into it. It is therefore the place for the outer spacing. */
|
||||
.fhc-contentcomponent {
|
||||
display: block;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.fhc-contentcomponent:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.fhc-contentcomponent:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fhc-contentcomponent .alert {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
/* Person block */
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
|
||||
/* PersonBlock.js. It carries person-block and every row of oe-personen. */
|
||||
.fhc-contentcomponent-person {
|
||||
/* flow-root contains the floated photo. It spares the clearfix element, which the
|
||||
component does not render. */
|
||||
display: flow-root;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--fhc-cc-border);
|
||||
border-radius: var(--fhc-cc-radius);
|
||||
background-color: var(--fhc-cc-surface);
|
||||
/* The block carries mb-3. The gap of the two lists below owns the spacing instead,
|
||||
so the last block does not push the content under it. Bootstrap writes the utility
|
||||
with !important, so this needs it as well. */
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* The photo carries its width and its object-fit as an inline style. No rule here can
|
||||
beat that, so only the placement is set. */
|
||||
.fhc-contentcomponent-person > img {
|
||||
float: left;
|
||||
margin-right: 1rem;
|
||||
border-radius: var(--fhc-cc-radius);
|
||||
}
|
||||
|
||||
/* The name. It is the first div, with or without a photo above it. */
|
||||
.fhc-contentcomponent-person > div:first-of-type {
|
||||
font-size: 1.05em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Function, phone, room and mail. The mail and phone links keep the link colour. */
|
||||
.fhc-contentcomponent-person > div:not(:first-of-type) {
|
||||
font-size: 0.95em;
|
||||
color: var(--fhc-cc-muted);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
/* oe-personen, person-block */
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
|
||||
.fhc-contentcomponent-oepersonen,
|
||||
.fhc-contentcomponent-personkontakt {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
/* dms-liste, dms-dokumente */
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
|
||||
/* Both render DokumentListe.js: ul.list-unstyled with one li per document. */
|
||||
.fhc-contentcomponent-dmsliste ul,
|
||||
.fhc-contentcomponent-dmsdokumente ul {
|
||||
border: 1px solid var(--fhc-cc-border);
|
||||
border-radius: var(--fhc-cc-radius);
|
||||
/* Keeps the first and the last row inside the rounded corners. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fhc-contentcomponent-dmsliste li,
|
||||
.fhc-contentcomponent-dmsdokumente li {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--fhc-cc-border);
|
||||
/* The row carries mb-2. The line above separates the rows instead. */
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.fhc-contentcomponent-dmsliste li:first-child,
|
||||
.fhc-contentcomponent-dmsdokumente li:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.fhc-contentcomponent-dmsliste li:hover,
|
||||
.fhc-contentcomponent-dmsdokumente li:hover {
|
||||
background-color: var(--fhc-cc-surface);
|
||||
}
|
||||
|
||||
/* The file type icon sits inside the link and would take the link colour. A fixed width
|
||||
lines the names up over every row, whatever the icon is. */
|
||||
.fhc-contentcomponent-dmsliste li i,
|
||||
.fhc-contentcomponent-dmsdokumente li i {
|
||||
width: 1.25rem;
|
||||
margin-right: 0.25rem;
|
||||
text-align: center;
|
||||
color: var(--fhc-cc-accent);
|
||||
}
|
||||
|
||||
.fhc-contentcomponent-dmsliste li a,
|
||||
.fhc-contentcomponent-dmsdokumente li a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.fhc-contentcomponent-dmsliste li a:hover,
|
||||
.fhc-contentcomponent-dmsliste li a:focus,
|
||||
.fhc-contentcomponent-dmsdokumente li a:hover,
|
||||
.fhc-contentcomponent-dmsdokumente li a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* The description is the only block element in the row. It starts below the icon, so it
|
||||
takes the width of the icon as an indent and lines up with the document name. */
|
||||
.fhc-contentcomponent-dmsliste li > div,
|
||||
.fhc-contentcomponent-dmsdokumente li > div {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
/* contentchild-menu */
|
||||
/* --------------------------------------------------------------------------------- */
|
||||
|
||||
/* The successor of the legacy menubox. The accent rule replaces its grey background as
|
||||
the mark that says: this is a menu, not running text. */
|
||||
.fhc-contentcomponent-contentchildmenu ul {
|
||||
padding: 0.5rem;
|
||||
border-left: 3px solid var(--fhc-cc-accent);
|
||||
border-radius: 0 var(--fhc-cc-radius) var(--fhc-cc-radius) 0;
|
||||
background-color: var(--fhc-cc-surface);
|
||||
}
|
||||
|
||||
.fhc-contentcomponent-contentchildmenu li {
|
||||
/* The entry carries mb-1. The link padding owns the spacing instead. */
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* A block link makes the whole row the target, not the length of the title. */
|
||||
.fhc-contentcomponent-contentchildmenu a {
|
||||
display: block;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: var(--fhc-cc-radius);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.fhc-contentcomponent-contentchildmenu a:hover,
|
||||
.fhc-contentcomponent-contentchildmenu a:focus {
|
||||
background-color: var(--fhc-cc-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/* Full-height shell. Without a height chain down to .cms-admin the page itself scrolls
|
||||
and the tabs leave the screen while paging through the tree. */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#main {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cms-admin {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The split component only caps its height with max-height, so give it a definite one. */
|
||||
.cms-admin .horizontalsplit-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cms-admin .horizontalsplitted {
|
||||
min-width: 0;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.cms-admin-main {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cms-admin-tab-content {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Anchor for the overlay that blocks the view during a content switch. */
|
||||
.cms-admin {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cms-admin-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* content locker to groups get a gold padlock - resembles legacy cms */
|
||||
.cms-tree-lock {
|
||||
color: #f0c000;
|
||||
text-shadow: 0 0 1px #8a6000;
|
||||
}
|
||||
|
||||
/* A frame swallows the mouse events that reach it, so dragging the split handle over the
|
||||
preview ends the drag. Both frames go through for the length of the drag, so the events
|
||||
reach the page underneath. Only a drag sets this class, so the preview stays clickable
|
||||
the rest of the time. */
|
||||
.cms-preview-dragging iframe {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The path down to a search hit, and the subtree under it. Both are context, so they step
|
||||
back and let the hit itself carry the normal weight. */
|
||||
.cms-tree-ancestor {
|
||||
font-size: 0.8em;
|
||||
color: #6c757d;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* The path is context and keeps the grey, but it leads somewhere, so it underlines on
|
||||
hover like any other link. */
|
||||
.cms-tree-ancestor a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cms-tree-ancestor a:hover,
|
||||
.cms-tree-ancestor a:focus {
|
||||
color: var(--bs-link-color, #0d6efd);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cms-tree-context > div > a,
|
||||
.cms-tree-context > div > span {
|
||||
font-size: 0.85em;
|
||||
color: #6c757d;
|
||||
}
|
||||
@@ -121,9 +121,8 @@
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
/* contrast: #777 reaches 4.48:1 on the odd and 4:1 on the even rows (#f2f2f2), #6c6c6c 5.3:1 and 4.7:1 */
|
||||
.tabulator-row.tabulator-unselectable:not(.tabulator-calcs) {
|
||||
color: #6c6c6c !important;
|
||||
color: #777 !important;
|
||||
}
|
||||
|
||||
.tabulator-cell .btn {
|
||||
@@ -157,23 +156,3 @@
|
||||
color: #fff;
|
||||
background: #1d68cd;
|
||||
}
|
||||
|
||||
/* contrast (WCAG 1.4.11): tabulator_simple draws the inactive sort arrow in #bbb (1.9:1) and the
|
||||
* placeholder in #ccc (1.6:1). #8f8f8f (3.2:1) is close to the grey of the active arrow, so the
|
||||
* active arrow gets the primary color (6.8:1) */
|
||||
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-sorter .tabulator-arrow,
|
||||
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
|
||||
border-bottom-color: #8f8f8f;
|
||||
}
|
||||
|
||||
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
|
||||
border-bottom-color: var(--fhc-primary, #006095);
|
||||
}
|
||||
|
||||
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
|
||||
border-top-color: var(--fhc-primary, #006095);
|
||||
}
|
||||
|
||||
.tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/* the panel of js/directives/inViewTooltip.js. place() sets its place, max width and max height
|
||||
inside the viewport, so neither a scrolling container nor a modal body clips it */
|
||||
.fhc-in-view-tooltip {
|
||||
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary, #0d6efd);
|
||||
position: fixed;
|
||||
z-index: 1080;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bs-body-bg, #fff);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
color: var(--bs-body-color, #212529);
|
||||
font-size: 0.875rem;
|
||||
font-weight: normal;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* the dark primary reaches 2:1 on the dark panel, the highlight 3.9:1 */
|
||||
html.dark .fhc-in-view-tooltip {
|
||||
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary-highlight, #6ea8fe);
|
||||
}
|
||||
|
||||
/* the body scrolls, so the progress border stays on the panel edge */
|
||||
.fhc-in-view-tooltip-body {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
white-space: pre-line; /* the text keeps its line breaks */
|
||||
}
|
||||
|
||||
/* the hover timer of a sticky panel: after a pause the border fills up clockwise, then the panel sticks.
|
||||
The 2 px stroke covers the 1 px border and 1 px inside it */
|
||||
.fhc-in-view-tooltip-progress {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fhc-in-view-tooltip-progress rect {
|
||||
fill: none;
|
||||
stroke: var(--fhc-in-view-tooltip-progress);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: var(--fhc-in-view-tooltip-perimeter);
|
||||
stroke-dashoffset: var(--fhc-in-view-tooltip-perimeter);
|
||||
}
|
||||
|
||||
.fhc-in-view-tooltip--holding .fhc-in-view-tooltip-progress rect {
|
||||
animation: fhc-in-view-tooltip-hold var(--fhc-in-view-tooltip-hold-fill) ease-in var(--fhc-in-view-tooltip-hold-delay) forwards;
|
||||
}
|
||||
|
||||
/* a click during the hover timer fills the rest of the border quickly */
|
||||
.fhc-in-view-tooltip--pinned .fhc-in-view-tooltip-progress rect {
|
||||
stroke-dashoffset: 0;
|
||||
transition: stroke-dashoffset 0.15s linear;
|
||||
}
|
||||
|
||||
@keyframes fhc-in-view-tooltip-hold {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* the ? icon of components/InViewHelp.js takes the color of its context, e.g. white in the blue
|
||||
modal header. A class like text-muted overrides it */
|
||||
.fhc-in-view-help-btn,
|
||||
.fhc-in-view-help-btn:hover,
|
||||
.fhc-in-view-help-btn:focus-visible,
|
||||
.btn.fhc-in-view-help-btn:active {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.fhc-in-view-help-btn {
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -188,9 +188,6 @@
|
||||
--fhc-light: var(--fhc-white-50);
|
||||
|
||||
--fhc-cis-menu-lvl-1-color: var(--fhc-white-50);
|
||||
|
||||
/* input borders, 3:1 against the background and the tinted card headers (#f8f8f9) */
|
||||
--fhc-input-border: #868e96;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
@@ -236,8 +233,6 @@
|
||||
--fhc-dark: var(--fhc-black-50);
|
||||
--fhc-light: var(--fhc-white-70);
|
||||
|
||||
/* input borders, 3:1 against the background */
|
||||
--fhc-input-border: #7c8791;
|
||||
}
|
||||
|
||||
:root.purple {
|
||||
@@ -318,13 +313,12 @@ html.dark .border-primary {
|
||||
border-color: var(--fhc-primary) !important;
|
||||
}
|
||||
|
||||
/* fhc danger: --fhc-danger is a light red for text and borders on the dark background. White text
|
||||
* on it reaches 1.2:1, the backgrounds use the dark red (6.5:1) */
|
||||
/* fhc danger*/
|
||||
html.dark .bg-danger,
|
||||
html.dark .btn-danger,
|
||||
html.dark .btn-outline-danger {
|
||||
background-color: var(--fhc-red-60) !important;
|
||||
border-color: var(--fhc-red-60) !important;
|
||||
background-color: var(--fhc-danger) !important;
|
||||
border-color: var(--fhc-danger) !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -333,25 +327,6 @@ html.dark .btn-danger:hover {
|
||||
filter: brightness(90%);
|
||||
}
|
||||
|
||||
/* the dark primevue theme draws p-button-danger (e.g. FhcAlert.confirmDelete) light pink with black text,
|
||||
* use the dark red of .btn-danger */
|
||||
html.dark .p-button.p-button-danger {
|
||||
color: white;
|
||||
background: var(--fhc-red-60);
|
||||
border-color: var(--fhc-red-60);
|
||||
}
|
||||
|
||||
html.dark .p-button.p-button-danger:enabled:hover,
|
||||
html.dark .p-button.p-button-danger:enabled:active {
|
||||
color: white;
|
||||
background: var(--fhc-red-70);
|
||||
border-color: var(--fhc-red-70);
|
||||
}
|
||||
|
||||
html.dark .p-button.p-button-danger:enabled:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(225, 110, 120, 0.5);
|
||||
}
|
||||
|
||||
html.dark .text-danger {
|
||||
color: var(--fhc-danger) !important;
|
||||
}
|
||||
@@ -360,55 +335,6 @@ html.dark .border-danger {
|
||||
border-color: var(--fhc-danger) !important;
|
||||
}
|
||||
|
||||
/* bootstrap draws btn-outline-secondary in #6c757d: 4.3:1 on the light dialog footer (#f6f6f6).
|
||||
* --fhc-gray-30 reaches 5.4:1 there, the white hover text 5.9:1 */
|
||||
html.light .btn-outline-secondary {
|
||||
--bs-btn-color: var(--fhc-gray-30);
|
||||
--bs-btn-border-color: var(--fhc-gray-30);
|
||||
--bs-btn-hover-bg: var(--fhc-gray-30);
|
||||
--bs-btn-hover-border-color: var(--fhc-gray-30);
|
||||
--bs-btn-active-bg: var(--fhc-gray-30);
|
||||
--bs-btn-active-border-color: var(--fhc-gray-30);
|
||||
}
|
||||
|
||||
/* bootstrap keeps #6c757d for btn-outline-secondary in the dark theme: 2.4:1 on the dark tables and dialogs */
|
||||
html.dark .btn-outline-secondary {
|
||||
--bs-btn-color: var(--fhc-white-90);
|
||||
--bs-btn-border-color: var(--fhc-white-90);
|
||||
--bs-btn-hover-color: var(--fhc-black-30);
|
||||
--bs-btn-hover-bg: var(--fhc-white-90);
|
||||
--bs-btn-hover-border-color: var(--fhc-white-90);
|
||||
--bs-btn-active-color: var(--fhc-black-30);
|
||||
--bs-btn-active-bg: var(--fhc-white-90);
|
||||
--bs-btn-active-border-color: var(--fhc-white-90);
|
||||
}
|
||||
|
||||
/* the primevue multiselect renders the check icon also in unchecked boxes and hides it by color:
|
||||
* white on white in the light theme, but #151515 on #20262e in the dark theme */
|
||||
html.dark .p-checkbox .p-checkbox-box:not(.p-highlight) .p-checkbox-icon {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* FhcAlert.confirm passes bootstrap classes to the primevue buttons. The primevue theme loads after
|
||||
* bootstrap, its .p-button colors replace the .btn colors: every button blue */
|
||||
.p-button.btn {
|
||||
color: var(--bs-btn-color);
|
||||
background: var(--bs-btn-bg);
|
||||
border-color: var(--bs-btn-border-color);
|
||||
}
|
||||
|
||||
.p-button.btn:enabled:hover {
|
||||
color: var(--bs-btn-hover-color);
|
||||
background: var(--bs-btn-hover-bg);
|
||||
border-color: var(--bs-btn-hover-border-color);
|
||||
}
|
||||
|
||||
.p-button.btn:enabled:active {
|
||||
color: var(--bs-btn-active-color);
|
||||
background: var(--bs-btn-active-bg);
|
||||
border-color: var(--bs-btn-active-border-color);
|
||||
}
|
||||
|
||||
|
||||
html.dark .tabulator,
|
||||
html.dark .tabulator .tabulator-header,
|
||||
@@ -429,52 +355,6 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover {
|
||||
background-color: var(--fhc-tertiary) !important;
|
||||
}
|
||||
|
||||
/* the active sort arrow of tabulator_simple (#666) reaches 2:1 on the dark header */
|
||||
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
|
||||
border-bottom-color: var(--fhc-text);
|
||||
}
|
||||
|
||||
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
|
||||
border-top-color: var(--fhc-text);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-sorter.tabulator-col-sorter-element .tabulator-arrow:hover {
|
||||
border-bottom-color: var(--fhc-text);
|
||||
border-top-color: var(--fhc-text);
|
||||
}
|
||||
}
|
||||
|
||||
/* the group toggle arrow of tabulator_simple (#666) reaches 2:1 on the dark rows */
|
||||
html.dark .tabulator .tabulator-row.tabulator-group:not(.tabulator-group-visible) .tabulator-arrow {
|
||||
border-left-color: var(--fhc-text);
|
||||
}
|
||||
|
||||
html.dark .tabulator .tabulator-row.tabulator-group.tabulator-group-visible .tabulator-arrow {
|
||||
border-top-color: var(--fhc-text);
|
||||
}
|
||||
|
||||
/* the footer stays white otherwise: the page buttons get the white button text of the dark color scheme */
|
||||
html.dark .tabulator .tabulator-footer {
|
||||
background-color: var(--fhc-tertiary);
|
||||
border-top-color: var(--fhc-border);
|
||||
color: var(--fhc-text);
|
||||
}
|
||||
|
||||
html.dark .tabulator .tabulator-footer .tabulator-page {
|
||||
background: transparent;
|
||||
border-color: var(--fhc-white-90);
|
||||
color: var(--fhc-text);
|
||||
}
|
||||
|
||||
html.dark .tabulator .tabulator-footer .tabulator-page.active {
|
||||
color: var(--fhc-red-30);
|
||||
}
|
||||
|
||||
html.dark .tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
|
||||
color: var(--fhc-white-90);
|
||||
}
|
||||
|
||||
.p-paginator{
|
||||
background-color: var(--fhc-background) !important;
|
||||
}
|
||||
@@ -496,35 +376,4 @@ html.dark .tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-pl
|
||||
|
||||
.p-link:focus {
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--fhc-primary), 0.5) !important;
|
||||
}
|
||||
|
||||
/* input borders: 3:1 against the background (WCAG 1.4.11), the bootstrap, primevue and datepicker defaults
|
||||
* reach 1.3 - 1.9:1. Focus and checked states of the libraries stay */
|
||||
.form-control,
|
||||
.form-select,
|
||||
.form-check-input,
|
||||
.p-inputtext,
|
||||
.p-inputtext:enabled:hover:not(:focus),
|
||||
.p-dropdown,
|
||||
.p-dropdown:not(.p-disabled):hover:not(.p-focus),
|
||||
.p-multiselect,
|
||||
.p-multiselect:not(.p-disabled):hover:not(.p-focus),
|
||||
.p-checkbox .p-checkbox-box,
|
||||
.p-radiobutton .p-radiobutton-box {
|
||||
border-color: var(--fhc-input-border, #868e96);
|
||||
}
|
||||
|
||||
/* the datepicker css loads after the theme, html raises the specificity */
|
||||
html .dp__theme_light,
|
||||
html .dp__theme_dark {
|
||||
--dp-border-color: var(--fhc-input-border, #868e96);
|
||||
--dp-border-color-hover: var(--fhc-border-highlight, #495057);
|
||||
}
|
||||
|
||||
/* the light datepicker (also in the dark cis4 theme) draws the days of other months in #c0c4cc (1.8:1),
|
||||
* the icons in #959595 (3:1) and the selected day in #f8f5f5 on #1976d2 (4.2:1) */
|
||||
html .dp__theme_light {
|
||||
--dp-secondary-color: #6c6c6c;
|
||||
--dp-icon-color: #6c757d;
|
||||
--dp-primary-text-color: #fff;
|
||||
}
|
||||
@@ -16,8 +16,7 @@
|
||||
*/
|
||||
|
||||
export default {
|
||||
// preview flag to suppress click log entry. The CMS admin preview passes it.
|
||||
content(content_id, version=null, sprache=null, sichtbar=null, preview=false) {
|
||||
content(content_id, version=null, sprache=null, sichtbar=null) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Cms/content',
|
||||
@@ -25,8 +24,7 @@ export default {
|
||||
content_id,
|
||||
...(version ? { version } : {}),
|
||||
...(sprache ? { sprache } : {}),
|
||||
...(sichtbar ? { sichtbar } : {}),
|
||||
...(preview ? { preview: 1 } : {})
|
||||
...(sichtbar ? { sichtbar } : {})
|
||||
}
|
||||
};
|
||||
},
|
||||
@@ -56,53 +54,6 @@ export default {
|
||||
url: '/api/frontend/v1/Cms/getNewsRowCount'
|
||||
};
|
||||
},
|
||||
// Used by the CMS content component "oe-personen".
|
||||
// tiefe 0 asks about the one unit, higher takes that many levels of sub units with it.
|
||||
getOePersonen(oe_kurzbz, foto, tiefe) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Cms/getOePersonen',
|
||||
params: {
|
||||
oe_kurzbz,
|
||||
...(foto ? { foto: 1 } : {}),
|
||||
...(tiefe ? { tiefe } : {})
|
||||
}
|
||||
};
|
||||
},
|
||||
// Used by the CMS content component "person-block".
|
||||
getPerson(uid, foto) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Cms/getPerson',
|
||||
params: { uid, ...(foto ? { foto: 1 } : {}) }
|
||||
};
|
||||
},
|
||||
// Used by the CMS content component "dms-dokumente".
|
||||
getDmsDokumente(dms_ids) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Cms/getDmsDokumente',
|
||||
params: { dms_ids }
|
||||
};
|
||||
},
|
||||
// Used by the CMS content component "dms-liste".
|
||||
// tiefe 0 asks about the one category, higher takes that many levels of sub categories
|
||||
// with it. The server checks the entitlement of every category it reaches.
|
||||
getDmsKategorie(kategorie_kurzbz, tiefe) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Cms/getDmsKategorie',
|
||||
params: { kategorie_kurzbz, ...(tiefe ? { tiefe } : {}) }
|
||||
};
|
||||
},
|
||||
// Used by the CMS content component "contentchild-menu".
|
||||
getContentChilds(content_id, sprache) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/Cms/getContentChilds',
|
||||
params: { content_id, ...(sprache ? { sprache } : {}) }
|
||||
};
|
||||
},
|
||||
getNewsExtra() {
|
||||
// TODO(chris): seems to be called from nowhere?
|
||||
return {
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
export default {
|
||||
|
||||
// --- CmsAdmin ---
|
||||
|
||||
getTree(menu, filter) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getTree',
|
||||
params: { menu, ...(filter ? { filter } : {}) }
|
||||
};
|
||||
},
|
||||
getSubtree(content_id) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getSubtree',
|
||||
params: { content_id }
|
||||
};
|
||||
},
|
||||
getContent(content_id) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getContent',
|
||||
params: { content_id }
|
||||
};
|
||||
},
|
||||
getContentsprache(content_id, sprache, version) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getContentsprache',
|
||||
params: { content_id, sprache, version }
|
||||
};
|
||||
},
|
||||
// Feeds the tree filter: child count, insertamum, updateamum and the own-edit flag.
|
||||
getTreeMeta() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getTreeMeta'
|
||||
};
|
||||
},
|
||||
// Where each content sits. The tree asks for the whole visible list at once, so the
|
||||
// hierarchy toggle costs one request and not one per node.
|
||||
getAncestors(content_ids) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getAncestors',
|
||||
params: { content_ids: content_ids.join(',') }
|
||||
};
|
||||
},
|
||||
// Views of one content. One counting query, so the tab loads it right away.
|
||||
// The server gates both click endpoints on the config flag and on the admin right,
|
||||
// so a caller without both gets an error instead of numbers.
|
||||
getClickCount(content_id, months) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getClickCount',
|
||||
params: { content_id, months }
|
||||
};
|
||||
},
|
||||
// The ranking over the whole log. Takes seconds, so the tab asks for it on request.
|
||||
getClickRanking(months, sprache) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getClickRanking',
|
||||
params: { months, sprache }
|
||||
};
|
||||
},
|
||||
getTemplates() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getTemplates'
|
||||
};
|
||||
},
|
||||
getOrganisationseinheiten() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getOrganisationseinheiten'
|
||||
};
|
||||
},
|
||||
getSprachen() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getSprachen'
|
||||
};
|
||||
},
|
||||
// Fills the DMS category field of the contentcomponent insert dialog.
|
||||
getDmsKategorien() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getDmsKategorien'
|
||||
};
|
||||
},
|
||||
// Fills the person field of the contentcomponent insert dialog.
|
||||
getMitarbeiter() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getMitarbeiter'
|
||||
};
|
||||
},
|
||||
// Fills the document field of the contentcomponent insert dialog.
|
||||
getDmsKategorieDokumente(kategorie_kurzbz) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getDmsKategorieDokumente',
|
||||
params: { kategorie_kurzbz }
|
||||
};
|
||||
},
|
||||
getUsage(content_id) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdmin/getUsage',
|
||||
params: { content_id }
|
||||
};
|
||||
},
|
||||
postContent(parent_content_id) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdmin/postContent',
|
||||
params: { parent_content_id }
|
||||
};
|
||||
},
|
||||
postTranslation(content_id, sprache, version, target_sprache) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdmin/postTranslation',
|
||||
params: { content_id, sprache, version, target_sprache }
|
||||
};
|
||||
},
|
||||
postVersion(content_id, sprache) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdmin/postVersion',
|
||||
params: { content_id, sprache }
|
||||
};
|
||||
},
|
||||
putProperties(content_id, sprache, version, template_kurzbz, oe_kurzbz, aktiv, menu_open, beschreibung, titel, sichtbar) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdmin/putProperties',
|
||||
params: { content_id, sprache, version, template_kurzbz, oe_kurzbz, aktiv, menu_open, beschreibung, titel, sichtbar }
|
||||
};
|
||||
},
|
||||
deleteContent(content_id) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdmin/deleteContent',
|
||||
params: { content_id }
|
||||
};
|
||||
},
|
||||
deleteContentsprache(content_id, sprache, version) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdmin/deleteContentsprache',
|
||||
params: { content_id, sprache, version }
|
||||
};
|
||||
},
|
||||
|
||||
// --- CmsAdminInhalt ---
|
||||
|
||||
getFormSchema(template_kurzbz) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminInhalt/getFormSchema',
|
||||
params: { template_kurzbz }
|
||||
};
|
||||
},
|
||||
getFormData(content_id, sprache, version) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminInhalt/getFormData',
|
||||
params: { content_id, sprache, version }
|
||||
};
|
||||
},
|
||||
getLock(content_id, sprache, version) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminInhalt/getLock',
|
||||
params: { content_id, sprache, version }
|
||||
};
|
||||
},
|
||||
getVersions(content_id, sprache) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminInhalt/getVersions',
|
||||
params: { content_id, sprache }
|
||||
};
|
||||
},
|
||||
putFormData(content_id, sprache, version, values) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminInhalt/putFormData',
|
||||
params: { content_id, sprache, version, values }
|
||||
};
|
||||
},
|
||||
postLock(contentsprache_id) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminInhalt/postLock',
|
||||
params: { contentsprache_id }
|
||||
};
|
||||
},
|
||||
deleteLock(contentsprache_id) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminInhalt/deleteLock',
|
||||
params: { contentsprache_id }
|
||||
};
|
||||
},
|
||||
deleteLockForced(contentsprache_id) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminInhalt/deleteLockForced',
|
||||
params: { contentsprache_id }
|
||||
};
|
||||
},
|
||||
|
||||
// --- CmsAdminStruktur ---
|
||||
|
||||
getGruppen(content_id) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/getGruppen',
|
||||
params: { content_id }
|
||||
};
|
||||
},
|
||||
getAllGruppen() {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/getAllGruppen'
|
||||
};
|
||||
},
|
||||
getChilds(content_id, sprache) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/getChilds',
|
||||
params: { content_id, sprache }
|
||||
};
|
||||
},
|
||||
// The hierarchy tab lists them above the children. A content can have several parents.
|
||||
getParents(content_id, sprache) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/getParents',
|
||||
params: { content_id, sprache }
|
||||
};
|
||||
},
|
||||
getPossibleChilds(content_id, sprache) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/getPossibleChilds',
|
||||
params: { content_id, sprache }
|
||||
};
|
||||
},
|
||||
postGruppe(content_id, gruppe_kurzbz) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/postGruppe',
|
||||
params: { content_id, gruppe_kurzbz }
|
||||
};
|
||||
},
|
||||
deleteGruppe(content_id, gruppe_kurzbz) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/deleteGruppe',
|
||||
params: { content_id, gruppe_kurzbz }
|
||||
};
|
||||
},
|
||||
postChild(content_id, child_content_id) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/postChild',
|
||||
params: { content_id, child_content_id }
|
||||
};
|
||||
},
|
||||
deleteChild(contentchild_id) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/deleteChild',
|
||||
params: { contentchild_id }
|
||||
};
|
||||
},
|
||||
putChildSort(contentchild_id, direction) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/putChildSort',
|
||||
params: { contentchild_id, direction }
|
||||
};
|
||||
},
|
||||
// Writes the whole order after a drag and drop. The ids carry the wanted order.
|
||||
putChildOrder(content_id, contentchild_ids) {
|
||||
return {
|
||||
method: 'post',
|
||||
url: '/api/frontend/v1/CmsAdminStruktur/putChildOrder',
|
||||
params: { content_id, contentchild_ids }
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -44,5 +44,15 @@ export default {
|
||||
studiensemester_kurzbz
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
getContainingOrNearestByDateRange(from, to) {
|
||||
return {
|
||||
method: 'get',
|
||||
url: 'api/frontend/v1/organisation/studiensemester/getContainingOrNearestByDateRange',
|
||||
params: {
|
||||
from,
|
||||
to,
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -150,8 +150,7 @@ const router = VueRouter.createRouter({
|
||||
}
|
||||
},
|
||||
{
|
||||
// optional version for cms admin preview
|
||||
path: `/CisVue/Cms/Content/:content_id/:version?`,
|
||||
path: `/CisVue/Cms/Content/:content_id`,
|
||||
name: 'Content',
|
||||
component: CmsContent,
|
||||
props: true
|
||||
@@ -351,9 +350,5 @@ app.directive('contrast', contrast);
|
||||
app.mount('#fhccontent');
|
||||
|
||||
router.afterEach((to, from, failure) => {
|
||||
// dont log cms admin preview
|
||||
if (to.query.preview === '1')
|
||||
return;
|
||||
|
||||
app.config.globalProperties.$api.call(ApiRouteInfo.info('cis4', to.fullPath));
|
||||
});
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import CmsAdmin from "../components/Cms/CmsAdmin.js";
|
||||
import PluginsPhrasen from "../plugins/Phrasen.js";
|
||||
|
||||
const ciPath = FHC_JS_DATA_STORAGE_OBJECT.app_root.replace(/(https:|)(^|\/\/)(.*?\/)/g, '')
|
||||
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router;
|
||||
|
||||
const router = VueRouter.createRouter({
|
||||
history: VueRouter.createWebHistory('/' + ciPath),
|
||||
routes: [
|
||||
{
|
||||
name: 'index',
|
||||
path: '/cms',
|
||||
component: CmsAdmin,
|
||||
children: [
|
||||
{
|
||||
// Only content_id is required. CmsAdmin fills the missing parts
|
||||
// from the content itself and rewrites the address.
|
||||
name: 'content',
|
||||
path: 'content/:content_id/:sprache?/:version?/:tab?',
|
||||
component: CmsAdmin
|
||||
}
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: { name: 'index' } }
|
||||
]
|
||||
});
|
||||
|
||||
FhcApps.router.makeExtendable(router);
|
||||
|
||||
const app = Vue.createApp({
|
||||
name: 'CmsApp'
|
||||
});
|
||||
|
||||
FhcApps.makeExtendable(app);
|
||||
|
||||
app
|
||||
.use(router)
|
||||
.use(primevue.config.default, {
|
||||
zIndex: {
|
||||
overlay: 1100
|
||||
}
|
||||
})
|
||||
.use(PluginsPhrasen)
|
||||
.mount('#main');
|
||||
@@ -73,7 +73,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// opacity 0.5 reaches 2.4:1 on the blue cis4 modal header, 0.75 reaches 3.6:1 (WCAG 1.4.11)
|
||||
getExpandButtonStyles() {
|
||||
const hovered = this.expandBtnHovered;
|
||||
const focused = this.expandBtnFocused;
|
||||
@@ -86,11 +85,12 @@ export default {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1em;
|
||||
opacity: 0.5;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s ease;
|
||||
opacity: ${focused || hovered ? '1' : '0.75'};
|
||||
opacity: ${focused ? '1' : hovered ? '0.75' : '0.5'};
|
||||
outline: ${focused ? '1px solid currentColor' : 'none'};
|
||||
outline-offset: 2px;`
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@ export default {
|
||||
methods: {
|
||||
fetchContent(){
|
||||
this.$api
|
||||
.call(ApiCms.content(
|
||||
this.content_id_internal, this.version, this.sprache, this.sichtbar, this.preview
|
||||
))
|
||||
.call(ApiCms.content(this.content_id_internal, this.version, this.sprache, this.sichtbar))
|
||||
.then(res => {
|
||||
this.$nextTick(function() {
|
||||
this.content = res.data.content;
|
||||
@@ -59,14 +57,8 @@ 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.$route?.query?.sprache || this.$p.user_language.value;
|
||||
},
|
||||
// A preview renders the content without counting a view.
|
||||
preview(){
|
||||
return this.$route?.query?.preview === '1';
|
||||
return this.$p.user_language.value;
|
||||
},
|
||||
computeContentType: function () {
|
||||
switch (this.content_type) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { replaceRelativeLegacyLink } from "../../../../helpers/LegacyLinkReplaceHelper.js"
|
||||
import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js";
|
||||
export default {
|
||||
name: "GeneralComponent",
|
||||
props:{
|
||||
@@ -7,13 +6,7 @@ export default {
|
||||
type:String,
|
||||
required:true,
|
||||
},
|
||||
content_id:{
|
||||
type: [Number, String],
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ContentcomponentHost
|
||||
},
|
||||
methods: {
|
||||
sanitizeLegacyTables(table) {
|
||||
|
||||
@@ -131,7 +124,7 @@ export default {
|
||||
template: /*html*/ `
|
||||
<!-- div that contains the content -->
|
||||
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
|
||||
<contentcomponent-host :content="content" :content-id="content_id"></contentcomponent-host>
|
||||
<div v-html="content" ></div>
|
||||
</div></div></div>
|
||||
<p v-else>Content was not found</p>
|
||||
`,
|
||||
|
||||
@@ -134,7 +134,6 @@ export default {
|
||||
template: /*html*/ `
|
||||
<!-- div that contains the content -->
|
||||
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
|
||||
<!-- No contentcomponent-host on purpose: news contents stay static by convention. -->
|
||||
<div v-html="content" ></div>
|
||||
</div></div></div>
|
||||
<p v-else>Content was not found</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js";
|
||||
|
||||
export default {
|
||||
name: "RaumComponent",
|
||||
data() {
|
||||
@@ -15,9 +15,6 @@ export default {
|
||||
type: [Number, String],
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ContentcomponentHost
|
||||
},
|
||||
methods: {
|
||||
sanitizeLegacyTables(table) {
|
||||
|
||||
@@ -138,7 +135,7 @@ export default {
|
||||
<!-- div that contains the content -->
|
||||
<!-- TODO: test with more img content from cms-->
|
||||
<div v-if="imgContent"><img v-bind="imgContent"/></div>
|
||||
<contentcomponent-host v-else-if="content" :content="content" :content-id="content_id"></contentcomponent-host>
|
||||
<div v-html="content" v-else-if="content" ></div>
|
||||
<p v-else>Content was not found</p>
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -117,7 +117,6 @@ export default {
|
||||
></pagination>
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row">
|
||||
<!-- No contentcomponent-host on purpose: news stays static by convention. -->
|
||||
<div class="col" v-html="content">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import FhcCalendar from "../../Calendar/LvPlan.js";
|
||||
|
||||
import ApiLvPlan from '../../../api/factory/lvPlan.js';
|
||||
import ApiStudienSemester from '../../../api/factory/studiensemester.js';
|
||||
import ApiAuthinfo from '../../../api/factory/authinfo.js';
|
||||
|
||||
export const DEFAULT_MODE_LVPLAN_DESKTOP = "Week";
|
||||
@@ -16,13 +17,12 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
studiensemester_kurzbz: null,
|
||||
studiensemester_start: null,
|
||||
studiensemester_ende: null,
|
||||
uid: null,
|
||||
isMitarbeiter: false,
|
||||
isStudent: false,
|
||||
timezone: FHC_JS_DATA_STORAGE_OBJECT.timezone,
|
||||
startSemester: null,
|
||||
endSemester: null,
|
||||
};
|
||||
},
|
||||
inject: ["isMobile"],
|
||||
@@ -48,9 +48,12 @@ export default {
|
||||
return this.propsViewData?.mode;
|
||||
},
|
||||
downloadLinks() {
|
||||
if (!this.studiensemester_start || !this.studiensemester_ende || !this.uid)
|
||||
return false;
|
||||
const startDate = this.startSemester?.start;
|
||||
const endDate = this.endSemester ? this.endSemester.ende : this.startSemester?.ende;
|
||||
|
||||
if (!startDate || !endDate || !this.uid)
|
||||
return false;
|
||||
|
||||
let type = false;
|
||||
type = this.isStudent ? 'student' : type;
|
||||
type = this.isMitarbeiter ? 'lektor' : type;
|
||||
@@ -61,10 +64,10 @@ export default {
|
||||
|
||||
const opts = { zone: this.timezone };
|
||||
const start = luxon.DateTime
|
||||
.fromISO(this.studiensemester_start, opts)
|
||||
.fromISO(startDate, opts)
|
||||
.toUnixInteger();
|
||||
const ende = luxon.DateTime
|
||||
.fromISO(this.studiensemester_ende, opts)
|
||||
.fromISO(endDate, opts)
|
||||
.toUnixInteger();
|
||||
|
||||
const download_link = FHC_JS_DATA_STORAGE_OBJECT.app_root
|
||||
@@ -109,16 +112,21 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
updateRange(rangeInterval) {
|
||||
this.$api
|
||||
.call(ApiLvPlan.studiensemesterDateInterval(
|
||||
rangeInterval.end.startOf('week').toISODate()
|
||||
))
|
||||
.then(res => {
|
||||
this.studiensemester_kurzbz = res.data.studiensemester_kurzbz;
|
||||
this.studiensemester_start = res.data.start;
|
||||
this.studiensemester_ende = res.data.ende;
|
||||
});
|
||||
async updateRange(rangeInterval) {
|
||||
const semesterResponse = await this.$api.call(
|
||||
ApiStudienSemester.getContainingOrNearestByDateRange(
|
||||
rangeInterval.s.toISO().slice(0, 10),
|
||||
rangeInterval.e.toISO().slice(0, 10),
|
||||
),
|
||||
);
|
||||
this.startSemester =
|
||||
semesterResponse.data.length
|
||||
? semesterResponse.data[0]
|
||||
: null;
|
||||
this.endSemester =
|
||||
semesterResponse.data.length > 1
|
||||
? semesterResponse.data[1]
|
||||
: null;
|
||||
},
|
||||
getPromiseFunc(start, end) {
|
||||
return [
|
||||
@@ -142,8 +150,11 @@ export default {
|
||||
<div class="cis-lvplan-personal d-flex flex-column h-100">
|
||||
<h2>
|
||||
{{ $p.t('lehre/stundenplan') }}
|
||||
<span v-if="studiensemester_kurzbz" class="ps-3">
|
||||
{{ studiensemester_kurzbz }}
|
||||
<span v-if="startSemester?.studiensemester_kurzbz">
|
||||
{{ startSemester.studiensemester_kurzbz }}
|
||||
<span v-if="endSemester?.studiensemester_kurzbz">
|
||||
{{ "- " + endSemester.studiensemester_kurzbz }}
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
<hr>
|
||||
|
||||
@@ -5,6 +5,7 @@ import FhcCalendar from "../../Calendar/LvPlan.js";
|
||||
import ApiLvPlan from "../.././../api/factory/lvPlan.js";
|
||||
import ApiOtherLvPlan from "../.././../api/factory/otherLvPlan.js";
|
||||
import ApiAuthinfo from "../../../api/factory/authinfo.js";
|
||||
import ApiStudienSemester from '../../../api/factory/studiensemester.js';
|
||||
|
||||
export const DEFAULT_MODE_LVPLAN_DESKTOP = "Week";
|
||||
export const DEFAULT_MODE_LVPLAN_MOBILE = "List";
|
||||
@@ -22,9 +23,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
localProps: {},
|
||||
studiensemester_kurzbz: null,
|
||||
studiensemester_start: null,
|
||||
studiensemester_ende: null,
|
||||
isOtherPersonMitarbeiter: false,
|
||||
isOtherPersonStudent: false,
|
||||
currentStgBezeichnung: null,
|
||||
@@ -36,6 +34,8 @@ export default {
|
||||
photo: "",
|
||||
},
|
||||
timezone: FHC_JS_DATA_STORAGE_OBJECT.timezone,
|
||||
startSemester: null,
|
||||
endSemester: null,
|
||||
};
|
||||
},
|
||||
inject: ["isMobile"],
|
||||
@@ -67,11 +67,10 @@ export default {
|
||||
// do not show download links in otherLvPlan Mode
|
||||
return false;
|
||||
|
||||
if (
|
||||
!this.studiensemester_start ||
|
||||
!this.studiensemester_ende ||
|
||||
!this.propsViewData.otherUid
|
||||
)
|
||||
const startDate = this.startSemester?.start;
|
||||
const endDate = this.endSemester ? this.endSemester.ende : this.startSemester?.ende;
|
||||
|
||||
if (!startDate || !endDate || !this.uid)
|
||||
return false;
|
||||
|
||||
const type = this.isOtherPersonStudent
|
||||
@@ -84,11 +83,11 @@ export default {
|
||||
|
||||
const opts = { zone: this.timezone };
|
||||
const start = luxon.DateTime.fromISO(
|
||||
this.studiensemester_start,
|
||||
startDate,
|
||||
opts,
|
||||
).toUnixInteger();
|
||||
const ende = luxon.DateTime.fromISO(
|
||||
this.studiensemester_ende,
|
||||
endDate,
|
||||
opts,
|
||||
).toUnixInteger();
|
||||
|
||||
@@ -166,19 +165,21 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
updateRange(rangeInterval) {
|
||||
this.$api
|
||||
.call(
|
||||
ApiLvPlan.studiensemesterDateInterval(
|
||||
rangeInterval.end.startOf("week").toISODate(),
|
||||
),
|
||||
)
|
||||
.then((res) => {
|
||||
this.studiensemester_kurzbz =
|
||||
res.data.studiensemester_kurzbz;
|
||||
this.studiensemester_start = res.data.start;
|
||||
this.studiensemester_ende = res.data.ende;
|
||||
});
|
||||
async updateRange(rangeInterval) {
|
||||
const semesterResponse = await this.$api.call(
|
||||
ApiStudienSemester.getContainingOrNearestByDateRange(
|
||||
rangeInterval.s.toISO().slice(0, 10),
|
||||
rangeInterval.e.toISO().slice(0, 10),
|
||||
),
|
||||
);
|
||||
this.startSemester =
|
||||
semesterResponse.data.length
|
||||
? semesterResponse.data[0]
|
||||
: null;
|
||||
this.endSemester =
|
||||
semesterResponse.data.length > 1
|
||||
? semesterResponse.data[1]
|
||||
: null;
|
||||
},
|
||||
getPromiseFunc(start, end) {
|
||||
return [
|
||||
@@ -250,8 +251,14 @@ export default {
|
||||
<div class="d-flex flex-column h-100">
|
||||
<h2>
|
||||
<div class="d-flex flex-row justify-content-between align-items-center">
|
||||
<span>
|
||||
{{ $p.t('lehre/stundenplan') + (studiensemester_kurzbz ? " " + studiensemester_kurzbz : "") }}
|
||||
<span>
|
||||
{{ $p.t('lehre/stundenplan') }}
|
||||
<span v-if="startSemester?.studiensemester_kurzbz">
|
||||
{{ startSemester.studiensemester_kurzbz }}
|
||||
<span v-if="endSemester?.studiensemester_kurzbz">
|
||||
{{ "- " + endSemester.studiensemester_kurzbz }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<div @click="this.$router.push({name: 'ProfilView', params: {uid: propsViewData.otherUid}})" type="button" class="d-flex flex-row align-items-center gap-3">
|
||||
<span v-if="otherPersonData.fullNameWithTitle?.length">
|
||||
|
||||
@@ -186,7 +186,7 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="nav-main" class="offcanvas offcanvas-start" tabindex="-1" aria-labelledby="nav-main-btn" data-bs-backdrop="false">
|
||||
<nav id="nav-main" class="cis-main-menu offcanvas offcanvas-start" tabindex="-1" aria-labelledby="nav-main-btn" data-bs-backdrop="false">
|
||||
<div id="nav-main-sticky">
|
||||
<div class="d-flex flex-row h-100">
|
||||
<div class="offcanvas-body p-0">
|
||||
|
||||
@@ -162,6 +162,7 @@ export default {
|
||||
<div class="btn-group w-100">
|
||||
<a :target="target"
|
||||
:href="(entry.menu_open && hasFullLink) ? entry.url : null"
|
||||
draggable="false"
|
||||
@click="toggleCollapse"
|
||||
:class="{
|
||||
'btn btn-default rounded-0 text-start': true,
|
||||
@@ -180,12 +181,15 @@ export default {
|
||||
</div>
|
||||
<ul ref="children"
|
||||
class="nav w-100 collapse">
|
||||
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeContent" v-for="child in entry.childs" :key="child" :entry="child" :level="level + 1"/>
|
||||
<li>
|
||||
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeContent" v-for="child in entry.childs" :key="child" :entry="child" :level="level + 1"/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<a v-else
|
||||
:href="entry.url"
|
||||
:target="target"
|
||||
draggable="false"
|
||||
:class="{
|
||||
'btn btn-default rounded-0 w-100 text-start': true,
|
||||
['btn-level-' + level]: true,
|
||||
|
||||
@@ -66,24 +66,6 @@ export default {
|
||||
return "d-none d-xl-grid";
|
||||
}
|
||||
},
|
||||
// link into the FHC-Core-Anwesenheiten tool
|
||||
// digi_anw_data is attached per event by the extension's `extendStundenplanData` listener
|
||||
anwLink() {
|
||||
const d = this.event && this.event.digi_anw_data;
|
||||
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
|
||||
return null;
|
||||
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
|
||||
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
|
||||
+ '/extensions/FHC-Core-Anwesenheiten/';
|
||||
return base
|
||||
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
|
||||
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
|
||||
+ `&lvid=${encodeURIComponent(d.lv_id)}`
|
||||
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
|
||||
},
|
||||
anwLinkTitle() {
|
||||
return this.$p.t('global/anwKontrolleOeffnen');
|
||||
},
|
||||
},
|
||||
template: /*html*/`
|
||||
<div
|
||||
@@ -114,21 +96,6 @@ export default {
|
||||
... +{{ event.lektor.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="anwLink"
|
||||
:href="anwLink"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="d-flex align-items-center flex-shrink-0"
|
||||
style="margin-right: 4px"
|
||||
:title="anwLinkTitle"
|
||||
:aria-label="anwLinkTitle"
|
||||
draggable="false"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
>
|
||||
<i class="fa fa-arrow-up-right-from-square fs-5" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -49,20 +49,6 @@ export default {
|
||||
return this.event.ende;
|
||||
}
|
||||
return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes());
|
||||
},
|
||||
// Deep link into the attendance tool; only present when the anw extension wrote into digi_anw_data
|
||||
anwLink: function () {
|
||||
const d = this.event && this.event.digi_anw_data;
|
||||
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
|
||||
return null;
|
||||
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
|
||||
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
|
||||
+ '/extensions/FHC-Core-Anwesenheiten/';
|
||||
return base
|
||||
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
|
||||
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
|
||||
+ `&lvid=${encodeURIComponent(d.lv_id)}`
|
||||
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -174,16 +160,6 @@ export default {
|
||||
}}</th>
|
||||
<td>{{event.organisationseinheit}}</td>
|
||||
</tr>
|
||||
<tr v-if="anwLink">
|
||||
<th>{{
|
||||
$p.t('global','anwKontrolle')?
|
||||
$p.t('global','anwKontrolle')+':'
|
||||
:''
|
||||
}}</th>
|
||||
<td>
|
||||
<a :href="anwLink" target="_blank" rel="noopener" :aria-label="$p.t('global','anwKontrolleOeffnen')" :title="$p.t('global','anwKontrolleOeffnen')"><i class="fa fa-arrow-up-right-from-square me-1" style="color:#00649C" aria-hidden="true"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<lv-menu v-if="lvMenu.length && $route.name === 'MyLvPlan'" :containerStyles="['p-0']" :rowStyles="['m-0']" :menu="lvMenu" />
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import ApiCmsAdmin from '../../api/factory/cmsadmin.js';
|
||||
import ContentTree from './Tree/ContentTree.js';
|
||||
import ContentHeader from './Header/ContentHeader.js';
|
||||
import Properties from './Tabs/Properties.js';
|
||||
import ContentTab from './Tabs/Content.js';
|
||||
import Permissions from './Tabs/Permissions.js';
|
||||
import Hierarchy from './Tabs/Hierarchy.js';
|
||||
import History from './Tabs/History.js';
|
||||
import Delete from './Tabs/Delete.js';
|
||||
import Clickstats from './Tabs/Clickstats.js';
|
||||
import HorizontalSplit from '../horizontalsplit/horizontalsplit.js';
|
||||
|
||||
const TAB_COMPONENTS = {
|
||||
properties: Properties,
|
||||
content: ContentTab,
|
||||
permissions: Permissions,
|
||||
hierarchy: Hierarchy,
|
||||
history: History,
|
||||
delete: Delete
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: 'properties', phrase: 'cms/tabEigenschaften' },
|
||||
{ key: 'content', phrase: 'cms/tabInhalt' },
|
||||
{ key: 'permissions', phrase: 'cms/tabRechte' },
|
||||
{ key: 'hierarchy', phrase: 'cms/tabHierarchie' },
|
||||
{ key: 'history', phrase: 'cms/tabHistory' },
|
||||
{ key: 'delete', phrase: 'cms/tabLoeschen' }
|
||||
];
|
||||
|
||||
// The view renders the clickstats attribute only for an admin, and only with the config
|
||||
// flag set.
|
||||
const CLICKSTATS_TAB = { key: 'clickstats', phrase: 'cms/tabKlickstatistik' };
|
||||
|
||||
export default {
|
||||
name: 'CmsAdmin',
|
||||
components: {
|
||||
'cms-tree': ContentTree,
|
||||
'cms-content-header': ContentHeader,
|
||||
'horizontal-split': HorizontalSplit,
|
||||
...TAB_COMPONENTS
|
||||
},
|
||||
props: {
|
||||
cmsRoot: String,
|
||||
authUid: String,
|
||||
defaultLanguage: { type: String, default: 'German' },
|
||||
clickstats: { type: Boolean, default: false }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
contentId: null,
|
||||
sprache: null,
|
||||
version: null,
|
||||
tab: 'properties',
|
||||
contentInfo: null,
|
||||
pending: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
tabs() {
|
||||
return this.clickstats ? TABS.concat([CLICKSTATS_TAB]) : TABS;
|
||||
},
|
||||
tabComponents() {
|
||||
return this.clickstats
|
||||
? { ...TAB_COMPONENTS, clickstats: Clickstats }
|
||||
: TAB_COMPONENTS;
|
||||
},
|
||||
activeTabComponent() {
|
||||
return this.tabComponents[this.tab] || null;
|
||||
},
|
||||
busy() {
|
||||
return this.pending > 0;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.params': {
|
||||
handler(params) {
|
||||
const id = params.content_id ? Number(params.content_id) : null;
|
||||
const sprache = params.sprache || null;
|
||||
const version = params.version ? Number(params.version) : null;
|
||||
// unknown tab fallback
|
||||
const tab = this.tabComponents[params.tab] ? params.tab : 'properties';
|
||||
|
||||
if (id !== this.contentId || sprache !== this.sprache
|
||||
|| version !== this.version || tab !== this.tab) {
|
||||
this.contentId = id;
|
||||
this.sprache = sprache;
|
||||
this.version = version;
|
||||
this.tab = tab;
|
||||
|
||||
if (id === null) return;
|
||||
|
||||
if (this.contentInfo?.content_id !== id) {
|
||||
this.loadContentInfo().then(() => { this.applyRouteDefaults(); });
|
||||
} else {
|
||||
this.applyRouteDefaults();
|
||||
}
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadContentInfo() {
|
||||
if (this.contentId === null) {
|
||||
this.contentInfo = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.pending++;
|
||||
return this.$api
|
||||
.call(ApiCmsAdmin.getContent(this.contentId))
|
||||
.then(result => {
|
||||
this.contentInfo = result.data;
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError)
|
||||
.finally(() => { this.pending--; });
|
||||
},
|
||||
|
||||
// Language, version and tab are optional in the address. Fill in what the address
|
||||
// leaves out, and replace a value that the content does not have. Then write the
|
||||
// complete address back, so a reload and a copied link both work.
|
||||
applyRouteDefaults() {
|
||||
if (this.contentId === null || !this.contentInfo) return;
|
||||
|
||||
const languages = this.contentInfo.languages || [];
|
||||
const versions = this.contentInfo.versions || {};
|
||||
|
||||
let lang = this.sprache;
|
||||
if (lang === null || !languages.includes(lang)) {
|
||||
lang = languages.includes(this.defaultLanguage)
|
||||
? this.defaultLanguage
|
||||
: languages[0] || null;
|
||||
}
|
||||
|
||||
const langVersions = (lang && versions[lang]) ? versions[lang] : [];
|
||||
let ver = this.version;
|
||||
if (ver === null || !langVersions.includes(ver)) {
|
||||
ver = langVersions.length ? Math.max(...langVersions) : null;
|
||||
}
|
||||
|
||||
if (lang === this.sprache && ver === this.version) return;
|
||||
|
||||
this.sprache = lang;
|
||||
this.version = ver;
|
||||
// Replace, or the short address stays in the history and the back button
|
||||
// walks through both forms of the same page.
|
||||
this.pushRoute(true);
|
||||
},
|
||||
|
||||
selectContent(content_id) {
|
||||
if (content_id === null) {
|
||||
this.contentId = null;
|
||||
this.sprache = null;
|
||||
this.version = null;
|
||||
this.tab = 'properties';
|
||||
this.contentInfo = null;
|
||||
this.$router.push({ name: 'index' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set id, language and version in one flush, or the tabs query the new content
|
||||
// with the version of the old one.
|
||||
this.pending++;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getContent(content_id))
|
||||
.then(result => {
|
||||
this.contentId = content_id;
|
||||
this.contentInfo = result.data;
|
||||
this.preselectLanguageVersion();
|
||||
this.pushRoute();
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError)
|
||||
.finally(() => { this.pending--; });
|
||||
},
|
||||
|
||||
selectTab(tab) {
|
||||
this.tab = tab;
|
||||
this.pushRoute();
|
||||
},
|
||||
|
||||
selectLanguageVersion(sprache, version) {
|
||||
this.sprache = sprache;
|
||||
this.version = version;
|
||||
this.pushRoute();
|
||||
},
|
||||
|
||||
// Three grades of tree update. Pick the cheapest one that stays correct.
|
||||
// A full reload drops the expanded branches and refetches the filter meta,
|
||||
// so keep it for changes that move nodes between branches.
|
||||
reloadTree() {
|
||||
if (this.$refs.tree) this.$refs.tree.reload();
|
||||
},
|
||||
|
||||
refreshTreeNode(contentId) {
|
||||
if (this.$refs.tree) this.$refs.tree.refreshNode(contentId);
|
||||
},
|
||||
|
||||
patchTreeNode(payload) {
|
||||
if (this.$refs.tree) this.$refs.tree.patchNode(payload.content_id, payload.patch);
|
||||
},
|
||||
|
||||
preselectLanguageVersion() {
|
||||
if (!this.contentInfo) return;
|
||||
|
||||
const languages = this.contentInfo.languages || [];
|
||||
const versions = this.contentInfo.versions || {};
|
||||
|
||||
let lang = languages.includes(this.defaultLanguage)
|
||||
? this.defaultLanguage
|
||||
: languages[0] || null;
|
||||
|
||||
let ver = null;
|
||||
if (lang && versions[lang] && versions[lang].length) {
|
||||
ver = Math.max(...versions[lang]);
|
||||
}
|
||||
|
||||
this.sprache = lang;
|
||||
this.version = ver;
|
||||
},
|
||||
|
||||
pushRoute(replace = false) {
|
||||
if (this.contentId === null) {
|
||||
this.$router.push({ name: 'index' });
|
||||
return;
|
||||
}
|
||||
|
||||
// The optional parts are positional. Stop at the first missing one, or the
|
||||
// address carries an empty segment.
|
||||
const params = { content_id: this.contentId };
|
||||
if (this.sprache !== null) {
|
||||
params.sprache = this.sprache;
|
||||
if (this.version !== null) {
|
||||
params.version = this.version;
|
||||
params.tab = this.tab;
|
||||
}
|
||||
}
|
||||
|
||||
const target = { name: 'content', params };
|
||||
if (replace) {
|
||||
this.$router.replace(target);
|
||||
} else {
|
||||
this.$router.push(target);
|
||||
}
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="cms-admin h-100">
|
||||
<horizontal-split :default-ratio="[25, 75]">
|
||||
<template #left>
|
||||
<cms-tree
|
||||
ref="tree"
|
||||
:active-content-id="contentId"
|
||||
@select-content="selectContent"
|
||||
></cms-tree>
|
||||
</template>
|
||||
<template #right>
|
||||
<div class="cms-admin-main h-100 d-flex flex-column" v-if="contentId !== null">
|
||||
<cms-content-header
|
||||
:content-id="contentId"
|
||||
:sprache="sprache"
|
||||
:version="version"
|
||||
:contentInfo="contentInfo"
|
||||
@select-language-version="selectLanguageVersion"
|
||||
@reload-content-info="loadContentInfo"
|
||||
></cms-content-header>
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li class="nav-item" v-for="t in tabs" :key="t.key">
|
||||
<a class="nav-link"
|
||||
:class="{ active: tab === t.key }"
|
||||
href="#"
|
||||
@click.prevent="selectTab(t.key)"
|
||||
>{{ $p.t(t.phrase) }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cms-admin-tab-content flex-grow-1 overflow-auto">
|
||||
<component
|
||||
:is="activeTabComponent"
|
||||
:content-id="contentId"
|
||||
:sprache="sprache"
|
||||
:version="version"
|
||||
:contentInfo="contentInfo"
|
||||
@reload-content-info="loadContentInfo"
|
||||
@reload-tree="reloadTree"
|
||||
@refresh-tree-node="refreshTreeNode"
|
||||
@patch-tree-node="patchTreeNode"
|
||||
@select-content="selectContent"
|
||||
></component>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cms-admin-main h-100 d-flex align-items-center justify-content-center text-muted" v-else>
|
||||
{{ $p.t('cms/keinEintragGewaehlt') }}
|
||||
</div>
|
||||
</template>
|
||||
</horizontal-split>
|
||||
|
||||
<div v-if="busy" class="cms-admin-overlay">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{ $p.t('ui/loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,381 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
import {
|
||||
catalog,
|
||||
getDescriptor,
|
||||
CONTENTCOMPONENT_CLASS,
|
||||
CONTENTCOMPONENT_ATTR
|
||||
} from '../../Contentcomponents/catalog.js';
|
||||
|
||||
/**
|
||||
* The insert and edit dialogs for a contentcomponent marker.
|
||||
*
|
||||
* Lives apart from FieldWysiwyg so that file keeps to the editor itself. Everything that
|
||||
* knows about the catalog, the marker markup and the dialogs is here.
|
||||
*/
|
||||
|
||||
// Helper fields a picker adds to a dialog. They must not collide with a prop name, and
|
||||
// markerHtml ignores them because it walks the catalog, not the dialog data.
|
||||
const FELD_KATEGORIE = '__kategorie';
|
||||
const FELD_DOKUMENT = '__dokument';
|
||||
|
||||
// Filled in createContentcomponentDialog. The factory is no Vue component, so the
|
||||
// phrases plugin is handed in instead of read from an instance.
|
||||
let t = key => key;
|
||||
|
||||
// A function, not a constant: the text is translated when the dialog is built.
|
||||
const leereAuswahl = () => ({ value: '', text: t('ccAuswaehlen') });
|
||||
|
||||
// A prop source fills a dialog field with a list from the server. The key is the
|
||||
// "source" value in the catalog. A prop without a known source gets a plain text field.
|
||||
const SOURCES = {
|
||||
oe: {
|
||||
request: () => ApiCmsAdmin.getOrganisationseinheiten(),
|
||||
item: oe => ({
|
||||
value: oe.oe_kurzbz,
|
||||
text: (oe.bezeichnung || oe.oe_kurzbz)
|
||||
+ (oe.aktiv ? '' : ' (' + t('inaktiv') + ')')
|
||||
})
|
||||
},
|
||||
dmskategorie: {
|
||||
request: () => ApiCmsAdmin.getDmsKategorien(),
|
||||
item: kat => ({
|
||||
value: kat.kategorie_kurzbz,
|
||||
text: (kat.bezeichnung || kat.kategorie_kurzbz) + ' (' + kat.anzahl + ')'
|
||||
})
|
||||
},
|
||||
// Surname first
|
||||
person: {
|
||||
request: () => ApiCmsAdmin.getMitarbeiter(),
|
||||
item: person => ({
|
||||
value: person.uid,
|
||||
text: person.nachname + ' ' + person.vorname + ' (' + person.uid + ')'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
function escapeAttr(value)
|
||||
{
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Builds the marker. It stays empty: CIS4 renders the component into it, and every other
|
||||
// renderer shows nothing. NOTE: if a marker disappears on save, check the TinyMCE
|
||||
// serializer first. noneditable keeps the node, but an empty block is the exposed part.
|
||||
function markerHtml(name, descriptor, data)
|
||||
{
|
||||
let attrs = '';
|
||||
|
||||
for (const propName of Object.keys(descriptor.props || {}))
|
||||
{
|
||||
const value = data[propName];
|
||||
// false is an unchecked box. The absent attribute already means false.
|
||||
if (value === '' || value === false || value === undefined || value === null)
|
||||
continue;
|
||||
attrs += ' data-' + propName + '="' + escapeAttr(value) + '"';
|
||||
}
|
||||
|
||||
return '<div class="' + CONTENTCOMPONENT_CLASS + '" '
|
||||
+ CONTENTCOMPONENT_ATTR + '="' + name + '"' + attrs + '></div>';
|
||||
}
|
||||
|
||||
// Reads an existing marker back into dialog values.
|
||||
function readMarker(node)
|
||||
{
|
||||
const name = node.getAttribute(CONTENTCOMPONENT_ATTR);
|
||||
const descriptor = getDescriptor(name);
|
||||
|
||||
if (!descriptor)
|
||||
return null;
|
||||
|
||||
const values = {};
|
||||
for (const propName of Object.keys(descriptor.props || {}))
|
||||
values[propName] = node.getAttribute('data-' + propName) || '';
|
||||
|
||||
return { name: name, values: values };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options { api: the $api plugin, onChanged: called after a write }
|
||||
*/
|
||||
export function createContentcomponentDialog(options)
|
||||
{
|
||||
const api = options.api;
|
||||
const onChanged = options.onChanged || function () {};
|
||||
|
||||
t = (key, args) => options.p.t('cms/' + key, args);
|
||||
|
||||
const sourceItems = {};
|
||||
const sourceRequests = {};
|
||||
|
||||
// These lists are large and rarely needed. Load each one once, on demand.
|
||||
function loadSource(key)
|
||||
{
|
||||
if (sourceRequests[key])
|
||||
return sourceRequests[key];
|
||||
|
||||
const source = SOURCES[key];
|
||||
|
||||
sourceRequests[key] = api
|
||||
.call(source.request())
|
||||
.then(res => { sourceItems[key] = (res.data || []).map(source.item); })
|
||||
.catch(() => { sourceItems[key] = []; });
|
||||
|
||||
return sourceRequests[key];
|
||||
}
|
||||
|
||||
// Step one: pick the component. A second dialog then asks for its properties.
|
||||
// Two dialogs avoid the redial logic a single changing dialog would need.
|
||||
function openPicker(editor)
|
||||
{
|
||||
if (!catalog.length)
|
||||
return;
|
||||
|
||||
editor.windowManager.open({
|
||||
title: t('ccDialogTitel'),
|
||||
initialData: { component: catalog[0].name },
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [{
|
||||
type: 'selectbox',
|
||||
name: 'component',
|
||||
label: t('ccContentcomponent'),
|
||||
items: catalog.map(entry => ({
|
||||
value: entry.name,
|
||||
text: t(entry.labelPhrase)
|
||||
}))
|
||||
}]
|
||||
},
|
||||
buttons: [
|
||||
{ type: 'cancel', text: t('abbrechen') },
|
||||
{ type: 'submit', text: t('weiter'), primary: true }
|
||||
],
|
||||
onSubmit(dialog) {
|
||||
const name = dialog.getData().component;
|
||||
dialog.close();
|
||||
openProperties(editor, name, null, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Step two. node is null for an insert and the marker element for an edit.
|
||||
function openProperties(editor, name, values, node)
|
||||
{
|
||||
const descriptor = getDescriptor(name);
|
||||
|
||||
if (!descriptor)
|
||||
return;
|
||||
|
||||
const specs = Object.entries(descriptor.props || {});
|
||||
|
||||
const sources = specs
|
||||
.map(([, spec]) => spec.source)
|
||||
.filter((key, i, all) => SOURCES[key] && all.indexOf(key) === i);
|
||||
|
||||
// A document picker needs the category list even though no prop declares it.
|
||||
if (specs.some(([, spec]) => spec.picker === 'dms') && sources.indexOf('dmskategorie') === -1)
|
||||
sources.push('dmskategorie');
|
||||
|
||||
Promise.all(sources.map(loadSource)).then(() => {
|
||||
// Documents of the category currently chosen in the dialog. A category change
|
||||
// refills this and redials, because the items of a selectbox are fixed once
|
||||
// the dialog is built.
|
||||
let dokumente = [];
|
||||
|
||||
// A stored value that is no longer in the list, for example a person who left.
|
||||
// Without this extra option the select would fall back to its first entry and
|
||||
// silently replace the value on save. slice() keeps the cache clean.
|
||||
function listeFuer(propName, spec)
|
||||
{
|
||||
const liste = (sourceItems[spec.source] || []).slice();
|
||||
const wert = values && values[propName];
|
||||
|
||||
if (wert && !liste.some(eintrag => eintrag.value === wert))
|
||||
liste.unshift({ value: wert, text: t('ccNichtInDerAuswahl', [wert]) });
|
||||
|
||||
return liste;
|
||||
}
|
||||
|
||||
function itemsBauen()
|
||||
{
|
||||
return specs.reduce((alle, [propName, spec]) => {
|
||||
if (SOURCES[spec.source])
|
||||
{
|
||||
alle.push({
|
||||
type: 'selectbox',
|
||||
name: propName,
|
||||
label: spec.labelPhrase ? t(spec.labelPhrase) : propName,
|
||||
items: listeFuer(propName, spec)
|
||||
});
|
||||
return alle;
|
||||
}
|
||||
|
||||
if (spec.type === 'boolean')
|
||||
{
|
||||
alle.push({
|
||||
type: 'checkbox',
|
||||
name: propName,
|
||||
label: spec.labelPhrase ? t(spec.labelPhrase) : propName
|
||||
});
|
||||
return alle;
|
||||
}
|
||||
|
||||
alle.push({
|
||||
type: 'input',
|
||||
name: propName,
|
||||
label: spec.labelPhrase ? t(spec.labelPhrase) : propName
|
||||
});
|
||||
|
||||
if (spec.picker === 'dms')
|
||||
{
|
||||
alle.push({
|
||||
type: 'selectbox',
|
||||
name: propName + FELD_KATEGORIE,
|
||||
label: t('ccKategorie'),
|
||||
items: [leereAuswahl()].concat(sourceItems.dmskategorie || [])
|
||||
});
|
||||
alle.push({
|
||||
type: 'selectbox',
|
||||
name: propName + FELD_DOKUMENT,
|
||||
label: t('ccDokumentHinzufuegen'),
|
||||
items: [leereAuswahl()].concat(dokumente)
|
||||
});
|
||||
}
|
||||
|
||||
return alle;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function konfigBauen(daten)
|
||||
{
|
||||
return {
|
||||
title: t(descriptor.labelPhrase),
|
||||
initialData: daten,
|
||||
body: { type: 'panel', items: itemsBauen() },
|
||||
onChange(dialog, details) {
|
||||
aufAenderung(dialog, details.name);
|
||||
},
|
||||
buttons: [
|
||||
{ type: 'cancel', text: t('abbrechen') },
|
||||
{ type: 'submit', text: t('uebernehmen'), primary: true }
|
||||
],
|
||||
onSubmit(dialog) {
|
||||
const html = markerHtml(name, descriptor, dialog.getData());
|
||||
dialog.close();
|
||||
|
||||
if (node)
|
||||
editor.dom.setOuterHTML(node, html);
|
||||
else
|
||||
editor.insertContent(html);
|
||||
|
||||
onChanged();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function aufAenderung(dialog, feld)
|
||||
{
|
||||
// A new category means a new document list, and only redial can replace
|
||||
// the options of a selectbox.
|
||||
if (feld.endsWith(FELD_KATEGORIE))
|
||||
{
|
||||
const ziel = feld.slice(0, -FELD_KATEGORIE.length);
|
||||
const daten = dialog.getData();
|
||||
const kategorie = daten[feld];
|
||||
|
||||
daten[ziel + FELD_DOKUMENT] = '';
|
||||
|
||||
if (!kategorie)
|
||||
{
|
||||
dokumente = [];
|
||||
dialog.redial(konfigBauen(daten));
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.call(ApiCmsAdmin.getDmsKategorieDokumente(kategorie))
|
||||
.then(res => {
|
||||
dokumente = (res.data || []).map(dokument => ({
|
||||
value: String(dokument.dms_id),
|
||||
// A document outside the CIS search still renders here, so
|
||||
// the editor has to see which one that is.
|
||||
text: dokument.cis_suche
|
||||
? dokument.name
|
||||
: t('ccNichtInCisSuche', [dokument.name])
|
||||
}));
|
||||
})
|
||||
.catch(() => { dokumente = []; })
|
||||
.then(() => { dialog.redial(konfigBauen(daten)); });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Choosing a document appends it and clears the select, so the next one can
|
||||
// be picked straight away. The reset fires this handler again with an empty
|
||||
// value, which the guard below drops.
|
||||
if (feld.endsWith(FELD_DOKUMENT))
|
||||
{
|
||||
const ziel = feld.slice(0, -FELD_DOKUMENT.length);
|
||||
const daten = dialog.getData();
|
||||
const wert = daten[feld];
|
||||
|
||||
if (!wert)
|
||||
return;
|
||||
|
||||
const ids = String(daten[ziel] || '')
|
||||
.split(',')
|
||||
.map(teil => teil.trim())
|
||||
.filter(teil => teil);
|
||||
|
||||
if (ids.indexOf(wert) === -1)
|
||||
ids.push(wert);
|
||||
|
||||
const patch = {};
|
||||
patch[ziel] = ids.join(', ');
|
||||
patch[feld] = '';
|
||||
dialog.setData(patch);
|
||||
}
|
||||
}
|
||||
|
||||
// Every field needs a start value, otherwise the dialog reports it as unknown.
|
||||
const startdaten = {};
|
||||
for (const [propName, spec] of specs)
|
||||
{
|
||||
if (spec.type === 'boolean')
|
||||
{
|
||||
startdaten[propName] = !!values && values[propName] === 'true';
|
||||
continue;
|
||||
}
|
||||
|
||||
const liste = sourceItems[spec.source];
|
||||
startdaten[propName] = (values && values[propName])
|
||||
|| (liste && liste.length ? liste[0].value : '');
|
||||
|
||||
if (spec.picker === 'dms')
|
||||
{
|
||||
startdaten[propName + FELD_KATEGORIE] = '';
|
||||
startdaten[propName + FELD_DOKUMENT] = '';
|
||||
}
|
||||
}
|
||||
|
||||
editor.windowManager.open(konfigBauen(startdaten));
|
||||
});
|
||||
}
|
||||
|
||||
// Opens the property dialog of an existing marker.
|
||||
function openMarker(editor, node)
|
||||
{
|
||||
const marker = readMarker(node);
|
||||
|
||||
if (marker)
|
||||
openProperties(editor, marker.name, marker.values, node);
|
||||
}
|
||||
|
||||
return {
|
||||
openPicker: openPicker,
|
||||
openMarker: openMarker
|
||||
};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export default {
|
||||
name: 'FieldString',
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
label: String,
|
||||
required: Boolean,
|
||||
disabled: Boolean,
|
||||
type: { type: String, default: 'text' }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
{{ label }}<span v-if="required" class="text-danger"> *</span>
|
||||
</label>
|
||||
<input
|
||||
class="form-control"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,162 +0,0 @@
|
||||
import { CONTENTCOMPONENT_CLASS, CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js';
|
||||
import { createContentcomponentDialog } from './ContentcomponentDialog.js';
|
||||
|
||||
export default {
|
||||
name: 'FieldWysiwyg',
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
label: String,
|
||||
required: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
editor: null,
|
||||
editorId: 'cms-wysiwyg-' + Math.random().toString(36).substring(2, 11)
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getContent() {
|
||||
return this.editor ? this.editor.getContent() : this.modelValue;
|
||||
},
|
||||
|
||||
// Opens the legacy DMS browser and resolves with the relative URL it posts back.
|
||||
// Used for images and links, where the editor needs the file preview of that
|
||||
// browser. The contentcomponent dialog picks documents from a list instead, without
|
||||
// leaving the dialog. The bridge in cms/tinymce_dms.php sends { typ, url } to
|
||||
// window.opener and closes itself.
|
||||
openDmsBrowser() {
|
||||
const vm = this;
|
||||
|
||||
return new Promise(resolve => {
|
||||
if (vm._dmsListener)
|
||||
window.removeEventListener('message', vm._dmsListener);
|
||||
|
||||
const url = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/tinymce_dms.php';
|
||||
// The window name selects the bridge path in cms/tinymce_dms.php and survives
|
||||
// its internal navigation. The legacy admin opens the same page as 'DMS'.
|
||||
window.open(url, 'DMS_CIS4', 'width=1400,height=850,scrollbars=1,resizable=1');
|
||||
|
||||
const onMessage = (event) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (!event.data || event.data.typ !== 'fhc-dms-selection') return;
|
||||
|
||||
window.removeEventListener('message', onMessage);
|
||||
vm._dmsListener = null;
|
||||
resolve(event.data.url);
|
||||
};
|
||||
|
||||
vm._dmsListener = onMessage;
|
||||
window.addEventListener('message', onMessage);
|
||||
});
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newVal) {
|
||||
if (this.editor && this.editor.getContent() !== newVal) {
|
||||
this.editor.setContent(newVal || '');
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const vm = this;
|
||||
|
||||
// The dialogs live in their own module. This file stays with the editor itself.
|
||||
this.contentcomponentDialog = createContentcomponentDialog({
|
||||
api: this.$api,
|
||||
// The dialog module is no component, so it cannot reach the phrases itself.
|
||||
p: this.$p,
|
||||
onChanged: () => vm.$emit('update:modelValue', vm.editor.getContent())
|
||||
});
|
||||
|
||||
tinymce.init({
|
||||
target: this.$refs.editorArea,
|
||||
height: 500,
|
||||
language: 'de',
|
||||
// valid_elements: '*[*]' permits every tag and attribute. Existing content
|
||||
// contains audio, video and source tags. A stricter setting deletes them silently.
|
||||
valid_elements: '*[*]',
|
||||
// A contentcomponent marker is one atomic block. The editor must not type into it.
|
||||
noneditable_noneditable_class: CONTENTCOMPONENT_CLASS,
|
||||
// The marker renders nothing here, so the editor has to show that it is there.
|
||||
content_style: '.' + CONTENTCOMPONENT_CLASS + '{'
|
||||
+ 'display:block;margin:.5rem 0;padding:.5rem;min-height:2.5rem;'
|
||||
+ 'border:1px dashed #7a7a7a;background:#f2f2f2;cursor:pointer;}'
|
||||
+ '.' + CONTENTCOMPONENT_CLASS + '::before{'
|
||||
+ 'content:"Contentcomponent: " attr(' + CONTENTCOMPONENT_ATTR + ');'
|
||||
+ 'font:12px monospace;color:#555;}',
|
||||
forced_root_block: '',
|
||||
// No style_formats, and no styleselect in the toolbar below. The six formats
|
||||
// of the legacy admin are gone, and none of them earned a port.
|
||||
//
|
||||
// Three wrote a div, while every stylesheet targets a td: skin/styles/tw.css
|
||||
// declares td.cmscontent, td.menubox and td.teambox. The live content agrees,
|
||||
// it carries those classes on a td about 1400 times and on a div twice.
|
||||
// Tablesorter wrote tablesorter_col_0, which no file in this repository
|
||||
// reads, and skin/style.css.php never serves skin/tablesort.css.
|
||||
//
|
||||
// cmstable and screenshot_boxshadow did work, but only in the legacy
|
||||
// renderer. public/css/Cis4/Cms.css binds its copies to #cms, and CIS4
|
||||
// renders into #fhc-cms-content. CIS4 is the target, so they went too.
|
||||
//
|
||||
// A styling option for editors is a feature of its own. See
|
||||
// docs/cms-darstellung-entwurf.md.
|
||||
plugins: 'code lists link image table fullscreen paste searchreplace charmap directionality visualblocks anchor hr noneditable',
|
||||
toolbar: [
|
||||
'code | contentcomponent | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | fontsizeselect',
|
||||
'pastetext | searchreplace | bullist numlist | outdent indent blockquote | undo redo | link unlink anchor image | forecolor backcolor',
|
||||
'table | hr removeformat visualblocks | subscript superscript | charmap | ltr rtl | fullscreen'
|
||||
],
|
||||
// The relative form dms.php?id=N is mandatory. The legacy view runs under /cms/
|
||||
// and resolves it directly. The CIS4 view replaces it in CmsLib::getContent
|
||||
// with APP_ROOT . 'cms/dms.php'. An absolute URL breaks both paths.
|
||||
file_picker_callback: (callback, value, meta) => {
|
||||
vm.openDmsBrowser().then(callback);
|
||||
},
|
||||
setup: (editor) => {
|
||||
vm.editor = editor;
|
||||
|
||||
editor.ui.registry.addButton('contentcomponent', {
|
||||
text: vm.$p.t('cms/ccContentcomponent'),
|
||||
tooltip: vm.$p.t('cms/ccDialogTitel'),
|
||||
onAction: () => vm.contentcomponentDialog.openPicker(editor)
|
||||
});
|
||||
|
||||
// A double click on a marker reopens its property dialog.
|
||||
editor.on('dblclick', (event) => {
|
||||
const node = editor.dom.getParent(event.target, '.' + CONTENTCOMPONENT_CLASS);
|
||||
|
||||
if (node)
|
||||
vm.contentcomponentDialog.openMarker(editor, node);
|
||||
});
|
||||
|
||||
editor.on('init', () => {
|
||||
editor.setContent(vm.modelValue || '');
|
||||
if (vm.disabled) editor.mode.set('readonly');
|
||||
});
|
||||
editor.on('change input', () => {
|
||||
vm.$emit('update:modelValue', editor.getContent());
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this._dmsListener) {
|
||||
window.removeEventListener('message', this._dmsListener);
|
||||
this._dmsListener = null;
|
||||
}
|
||||
if (this.editor && tinymce.get(this.editor.id)) {
|
||||
tinymce.get(this.editor.id).remove();
|
||||
this.editor = null;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
{{ label }}<span v-if="required" class="text-danger"> *</span>
|
||||
</label>
|
||||
<textarea ref="editorArea" :id="editorId"></textarea>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import FieldString from './FieldString.js';
|
||||
import FieldWysiwyg from './FieldWysiwyg.js';
|
||||
|
||||
export default {
|
||||
name: 'XsdForm',
|
||||
components: {
|
||||
'field-string': FieldString,
|
||||
'field-wysiwyg': FieldWysiwyg
|
||||
},
|
||||
props: {
|
||||
schema: Object,
|
||||
modelValue: Object,
|
||||
disabled: Boolean
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
updateField(name, value) {
|
||||
this.$emit('update:modelValue', { ...this.modelValue, [name]: value });
|
||||
},
|
||||
collectValues() {
|
||||
const vals = { ...this.modelValue };
|
||||
if (!this.schema?.fields) return vals;
|
||||
for (const field of this.schema.fields) {
|
||||
if (field.type === 'wysiwyg') {
|
||||
const ref = this.$refs['wysiwyg-' + field.name];
|
||||
const comp = Array.isArray(ref) ? ref[0] : ref;
|
||||
if (comp?.getContent) vals[field.name] = comp.getContent();
|
||||
}
|
||||
}
|
||||
return vals;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div v-if="schema && schema.fields">
|
||||
<template v-for="field in schema.fields" :key="field.name">
|
||||
<field-wysiwyg
|
||||
v-if="field.type === 'wysiwyg'"
|
||||
:ref="'wysiwyg-' + field.name"
|
||||
:modelValue="modelValue[field.name] || ''"
|
||||
:label="field.name"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
@update:modelValue="updateField(field.name, $event)"
|
||||
></field-wysiwyg>
|
||||
<div v-else-if="field.type === 'boolean'" class="mb-3">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:checked="!!modelValue[field.name]"
|
||||
:disabled="disabled"
|
||||
@change="updateField(field.name, $event.target.checked)"
|
||||
:id="'field-' + field.name"
|
||||
>
|
||||
<label class="form-check-label" :for="'field-' + field.name">
|
||||
{{ field.name }}<span v-if="field.required" class="text-danger"> *</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<field-string
|
||||
v-else
|
||||
:modelValue="modelValue[field.name] || ''"
|
||||
:label="field.name"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
:type="field.type === 'date' ? 'date' : 'text'"
|
||||
@update:modelValue="updateField(field.name, $event)"
|
||||
></field-string>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,180 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
import VersionSelect from './VersionSelect.js';
|
||||
import LanguageSelect from './LanguageSelect.js';
|
||||
|
||||
export default {
|
||||
name: 'CmsContentHeader',
|
||||
components: {
|
||||
'version-select': VersionSelect,
|
||||
'sprache-select': LanguageSelect
|
||||
},
|
||||
props: {
|
||||
contentId: Number,
|
||||
sprache: String,
|
||||
version: Number,
|
||||
contentInfo: Object
|
||||
},
|
||||
emits: ['select-language-version', 'reload-content-info'],
|
||||
data() {
|
||||
return {
|
||||
titel: '',
|
||||
allSprachen: [],
|
||||
versionDetails: [],
|
||||
// Target of the translation dropdown, used once the list grows past a pair.
|
||||
neueSprache: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
languages() {
|
||||
return this.contentInfo?.languages || [];
|
||||
},
|
||||
versions() {
|
||||
if (!this.contentInfo?.versions || !this.sprache) return [];
|
||||
return this.contentInfo.versions[this.sprache] || [];
|
||||
},
|
||||
availableSprachen() {
|
||||
return this.allSprachen.filter(
|
||||
s => !this.languages.includes(s.sprache)
|
||||
);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
contentId() {
|
||||
this.loadTitel();
|
||||
this.loadVersionDetails();
|
||||
},
|
||||
sprache() {
|
||||
this.loadTitel();
|
||||
this.loadVersionDetails();
|
||||
},
|
||||
version() {
|
||||
this.loadTitel();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadTitel() {
|
||||
if (this.contentId == null || !this.sprache || !this.version) return;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
|
||||
.then(result => {
|
||||
this.titel = result.data?.titel || '';
|
||||
});
|
||||
},
|
||||
|
||||
loadVersionDetails() {
|
||||
if (this.contentId == null || !this.sprache) {
|
||||
this.versionDetails = [];
|
||||
return;
|
||||
}
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getVersions(this.contentId, this.sprache))
|
||||
.then(result => {
|
||||
this.versionDetails = result.data || [];
|
||||
});
|
||||
},
|
||||
|
||||
loadSprachen() {
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getSprachen())
|
||||
.then(result => {
|
||||
this.allSprachen = result.data || [];
|
||||
});
|
||||
},
|
||||
|
||||
selectVersion(ver) {
|
||||
this.$emit('select-language-version', this.sprache, ver);
|
||||
},
|
||||
|
||||
selectLanguage(lang) {
|
||||
const versions = this.contentInfo?.versions?.[lang] || [];
|
||||
const maxVersion = versions.length ? Math.max(...versions) : 1;
|
||||
this.$emit('select-language-version', lang, maxVersion);
|
||||
},
|
||||
|
||||
createVersion() {
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.postVersion(this.contentId, this.sprache))
|
||||
.then(result => {
|
||||
const newVersion = result.data.version;
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/neueVersionAngelegt'));
|
||||
this.$fhcAlert.alertInfo(this.$p.t('cms/neueVersionIstUnsichtbar'));
|
||||
this.$emit('reload-content-info');
|
||||
this.$emit('select-language-version', this.sprache, newVersion);
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
createTranslation(newSprache) {
|
||||
if (!newSprache) return;
|
||||
this.neueSprache = '';
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.postTranslation(
|
||||
this.contentId, this.sprache, this.version, newSprache
|
||||
))
|
||||
.then(() => {
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/uebersetzungAngelegt'));
|
||||
this.$emit('reload-content-info');
|
||||
this.$emit('select-language-version', newSprache, this.version);
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadSprachen();
|
||||
this.loadTitel();
|
||||
this.loadVersionDetails();
|
||||
},
|
||||
template: `
|
||||
<div class="px-3 pt-2 pb-2 border-bottom" v-if="contentInfo">
|
||||
<div class="mb-2 fw-bold">
|
||||
{{ $p.t('cms/contentId') }}: {{ contentId }}
|
||||
| {{ $p.t('cms/version') }}: {{ version }}
|
||||
| {{ $p.t('cms/sprache') }}: {{ sprache }}
|
||||
| {{ $p.t('cms/titel') }}: {{ titel }}
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<version-select
|
||||
:versions="versions"
|
||||
:version-details="versionDetails"
|
||||
:version="version"
|
||||
@select-version="selectVersion"
|
||||
></version-select>
|
||||
<sprache-select
|
||||
:languages="languages"
|
||||
:sprache="sprache"
|
||||
@select-language="selectLanguage"
|
||||
></sprache-select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
@click="createVersion"
|
||||
>{{ $p.t('cms/neueVersionAnlegen') }}</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<!-- One button per missing language is fine for a pair. An institution
|
||||
with more content languages would get a wall of them, each
|
||||
carrying the full label. -->
|
||||
<template v-if="availableSprachen.length > 2">
|
||||
<select class="form-select form-select-sm w-auto"
|
||||
v-model="neueSprache">
|
||||
<option value="">{{ $p.t('cms/spracheWaehlen') }}</option>
|
||||
<option v-for="s in availableSprachen" :key="s.sprache"
|
||||
:value="s.sprache">{{ s.sprache }}</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
:disabled="!neueSprache"
|
||||
@click="createTranslation(neueSprache)"
|
||||
>{{ $p.t('cms/uebersetzungAnlegen') }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-for="s in availableSprachen" :key="s.sprache"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
@click="createTranslation(s.sprache)"
|
||||
>{{ $p.t('cms/uebersetzungAnlegenIn', [s.sprache]) }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
export default {
|
||||
name: 'SpracheSelect',
|
||||
props: {
|
||||
languages: { type: Array, default: () => [] },
|
||||
sprache: String
|
||||
},
|
||||
emits: ['select-language'],
|
||||
computed: {
|
||||
// Two languages read faster as a pair of buttons, and that is the case here. Other
|
||||
// institutions run this software with more content languages, and there the row of
|
||||
// buttons pushes the rest of the header off the line.
|
||||
alsAuswahl() {
|
||||
return this.languages.length > 2;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<select v-if="alsAuswahl"
|
||||
class="form-select form-select-sm w-auto cms-sprache-select"
|
||||
:title="$p.t('cms/sprache')"
|
||||
:value="sprache"
|
||||
@change="$emit('select-language', $event.target.value)"
|
||||
>
|
||||
<option v-for="lang in languages" :key="lang" :value="lang">{{ lang }}</option>
|
||||
</select>
|
||||
<div v-else class="btn-group btn-group-sm cms-sprache-select" role="group">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
:key="lang"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="lang === sprache ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@click="$emit('select-language', lang)"
|
||||
>{{ lang }}</button>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
// Versions shown on each side of the open one.
|
||||
const WINDOW = 3;
|
||||
|
||||
export default {
|
||||
name: 'VersionSelect',
|
||||
props: {
|
||||
versions: { type: Array, default: () => [] },
|
||||
versionDetails: { type: Array, default: () => [] },
|
||||
version: Number
|
||||
},
|
||||
emits: ['select-version'],
|
||||
computed: {
|
||||
// A content can carry dozens of versions, and one button each breaks the header.
|
||||
// Show the open version with three neighbours on each side. The first and the last
|
||||
// stay reachable in one click, and a gap marks what the window leaves out.
|
||||
// Below the threshold the window would hide nothing, so show every version.
|
||||
items() {
|
||||
const all = this.versions;
|
||||
const active = all.indexOf(this.version);
|
||||
|
||||
if (active === -1 || all.length <= WINDOW * 2 + 3)
|
||||
return all.map(value => ({ type: 'version', value }));
|
||||
|
||||
const from = Math.max(0, active - WINDOW);
|
||||
const to = Math.min(all.length - 1, active + WINDOW);
|
||||
const items = [];
|
||||
|
||||
if (from > 0) {
|
||||
items.push({ type: 'version', value: all[0] });
|
||||
// One version between the first and the window needs no gap marker.
|
||||
if (from > 1) items.push({ type: 'gap', count: from - 1 });
|
||||
}
|
||||
|
||||
for (let i = from; i <= to; i++)
|
||||
items.push({ type: 'version', value: all[i] });
|
||||
|
||||
if (to < all.length - 1) {
|
||||
if (to < all.length - 2)
|
||||
items.push({ type: 'gap', count: all.length - 2 - to });
|
||||
items.push({ type: 'version', value: all[all.length - 1] });
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
sichtbarMap() {
|
||||
const map = {};
|
||||
for (const v of this.versionDetails) {
|
||||
map[v.version] = v.sichtbar;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isSichtbar(ver) {
|
||||
return this.sichtbarMap[ver] !== false;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<template v-for="(item, idx) in items" :key="item.type + idx">
|
||||
<button
|
||||
v-if="item.type === 'version'"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="[
|
||||
item.value === version ? 'btn-primary' : 'btn-outline-secondary',
|
||||
{ 'opacity-50': !isSichtbar(item.value) }
|
||||
]"
|
||||
@click="$emit('select-version', item.value)"
|
||||
>{{ item.value }}</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
disabled
|
||||
:title="$p.t('cms/weitereVersionen', [item.count])"
|
||||
>…</button>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,308 +0,0 @@
|
||||
// Safety net for the spinner.
|
||||
const CIS4_LOAD_TIMEOUT = 10000;
|
||||
|
||||
// Cis.js reads window.innerWidth and switches to its mobile layout below 767px. In a frame
|
||||
// that is the width of the frame, so these widths drive the real layout and not a zoom.
|
||||
// Its resize listener reacts on its own, which means no reload and no second long wait.
|
||||
const CIS4_WIDTHS = {
|
||||
mobile: 375,
|
||||
desktop: null
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'CmsPreview',
|
||||
props: {
|
||||
contentId: Number,
|
||||
sprache: String,
|
||||
version: Number,
|
||||
templateKurzbz: String,
|
||||
// The legacy renderer runs no Vue, so it draws nothing for a contentcomponent
|
||||
// marker. The CIS4 view mounts them, which is the whole point of switching.
|
||||
hasContentcomponents: Boolean,
|
||||
// False for a template whose xslt_xhtml_c4 is a copy of the legacy stylesheet.
|
||||
// Such a template emits a whole HTML document, and the CIS4 renderer writes that
|
||||
// into a div, so the layout is lost. Defaults true, so an older payload warns
|
||||
// about nothing.
|
||||
hasCis4Stylesheet: { type: Boolean, default: true },
|
||||
sichtbar: Boolean
|
||||
},
|
||||
data() {
|
||||
// The legacy renderer stays the default, so nothing changes until an editor asks
|
||||
// for the other one. The choice survives a page switch, like the menu switch.
|
||||
const stored = localStorage.getItem('cms/previewMode');
|
||||
const mode = (stored === 'legacy' || stored === 'cis4') ? stored : 'legacy';
|
||||
const width = localStorage.getItem('cms/previewWidth');
|
||||
return {
|
||||
mode: mode,
|
||||
// True while the split handle is dragged. See onSplitterDown.
|
||||
dragging: false,
|
||||
cis4Width: CIS4_WIDTHS.hasOwnProperty(width) ? width : 'desktop',
|
||||
// The CIS4 shell pulls its whole stack before it shows anything, which takes a
|
||||
// while. Start covered when the frame is the one on screen.
|
||||
cis4Loading: mode === 'cis4'
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
mode(value) {
|
||||
if (value === 'cis4') this.startCis4Load();
|
||||
else this.stopCis4Poll();
|
||||
},
|
||||
// A new content, language or version reloads the frame.
|
||||
cis4PreviewUrl() {
|
||||
if (this.mode === 'cis4') this.startCis4Load();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// The preview uses the legacy renderer cms/content.php. It reads xslt_xhtml, which
|
||||
// holds a complete HTML document for all eleven templates. The CIS4 route reads
|
||||
// xslt_xhtml_c4, where four templates hold a whole document instead of a fragment.
|
||||
previewUrl() {
|
||||
return FHC_JS_DATA_STORAGE_OBJECT.app_root
|
||||
+ 'cms/content.php?content_id=' + this.contentId
|
||||
+ '&version=' + this.version
|
||||
+ '&sprache=' + this.sprache
|
||||
+ '&sichtbar=';
|
||||
},
|
||||
// The CIS4 route takes content_id only. Its Vue route is
|
||||
// /CisVue/Cms/Content/:content_id, and extra segments hit the catch-all, which
|
||||
// redirects to the dashboard. CIS4 picks the version itself and the language from
|
||||
// the viewer profile.
|
||||
cis4Url() {
|
||||
return FHC_JS_DATA_STORAGE_OBJECT.app_root
|
||||
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
|
||||
+ '/CisVue/Cms/content/' + this.contentId;
|
||||
},
|
||||
// The second preview renders the content the way CIS4 does: the route loads the
|
||||
// CIS4 shell, which brings its stylesheets, its Vue app and the contentcomponents.
|
||||
// CISVUE-Header sets the body class in-frame by itself, and Cis.css hides the CIS4
|
||||
// header for it, so the frame shows the content and no navigation.
|
||||
// version travels as a route segment, because Cms/Content.js already declares that
|
||||
// prop and the route passes props. sprache and preview travel as query parameters,
|
||||
// because neither is a prop there.
|
||||
cis4PreviewUrl() {
|
||||
let url = FHC_JS_DATA_STORAGE_OBJECT.app_root
|
||||
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
|
||||
+ '/CisVue/Cms/content/' + this.contentId;
|
||||
|
||||
if (this.version !== null)
|
||||
url += '/' + this.version;
|
||||
|
||||
url += '?preview=1';
|
||||
|
||||
if (this.sprache)
|
||||
url += '&sprache=' + encodeURIComponent(this.sprache);
|
||||
|
||||
return url;
|
||||
},
|
||||
// The overlay sits on the wrapper, so the wrapper carries the width. Otherwise the
|
||||
// spinner would cover the whole pane while the frame is only 375px wide.
|
||||
cis4WrapStyle() {
|
||||
const px = CIS4_WIDTHS[this.cis4Width];
|
||||
// content-box on purpose: the frame reads its own window.innerWidth, and with
|
||||
// the usual border-box the border would eat two pixels. The preset is meant
|
||||
// to be the width the CIS4 app sees, so it must not depend on the frame.
|
||||
return px === null
|
||||
? ''
|
||||
: 'width: ' + px + 'px; box-sizing: content-box; max-width: 100%;'
|
||||
+ ' margin: 0 auto;';
|
||||
},
|
||||
// The redirect template navigates away with JavaScript. In an iframe this can close
|
||||
// the admin page. Therefore this case uses a link and not an iframe.
|
||||
isRedirect() {
|
||||
return this.templateKurzbz === 'redirect';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectMode(value) {
|
||||
localStorage.setItem('cms/previewMode', value);
|
||||
this.mode = value;
|
||||
},
|
||||
|
||||
// The split handle sits right next to the frame. As soon as the pointer crosses
|
||||
// into the frame, the frame document takes the mousemove and the mouseup, the
|
||||
// listeners HorizontalSplit put on the parent window never fire, and the drag
|
||||
// stops halfway. Turning pointer events off in the frame for the length of the
|
||||
// drag lets those events through to the page underneath.
|
||||
//
|
||||
// Capture phase on purpose: HorizontalSplit.dragStart calls stopPropagation, so a
|
||||
// listener that waits for the event to bubble back up never sees it at all.
|
||||
onSplitterDown(event) {
|
||||
const target = event.target;
|
||||
if (target && target.closest && target.closest('.horizontalsplitter'))
|
||||
this.dragging = true;
|
||||
},
|
||||
|
||||
onSplitterUp() {
|
||||
this.dragging = false;
|
||||
},
|
||||
|
||||
selectWidth(value) {
|
||||
localStorage.setItem('cms/previewWidth', value);
|
||||
this.cis4Width = value;
|
||||
|
||||
// A frame resized by its parent gets no resize event of its own, so Cis.js
|
||||
// would keep the window width it read at load and stay on that layout. Same
|
||||
// origin, so tell the frame. That spares a reload of the whole CIS4 stack.
|
||||
this.$nextTick(() => {
|
||||
try {
|
||||
const frame = this.$refs.cis4Frame;
|
||||
const win = frame && frame.contentWindow;
|
||||
if (win) win.dispatchEvent(new win.Event('resize'));
|
||||
} catch (e) {
|
||||
// Not reachable. The frame keeps the layout it loaded with.
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
startCis4Load() {
|
||||
this.stopCis4Poll();
|
||||
this.cis4Loading = true;
|
||||
},
|
||||
|
||||
// The frame fires load once the CIS4 shell is there, but the shell then fetches the
|
||||
// content over the API, so the load event alone clears the spinner too early and
|
||||
// leaves the placeholder text on screen. The frame is same origin, so wait for the
|
||||
// element the CIS4 renderer writes once it holds the content.
|
||||
onCis4Load() {
|
||||
this.stopCis4Poll();
|
||||
const started = Date.now();
|
||||
|
||||
this.cis4Poll = setInterval(() => {
|
||||
let done = false;
|
||||
|
||||
try {
|
||||
const doc = this.$refs.cis4Frame && this.$refs.cis4Frame.contentDocument;
|
||||
done = !!(doc && doc.getElementById('fhc-cms-content'));
|
||||
} catch (e) {
|
||||
// Not readable after all. Stop waiting rather than spin forever.
|
||||
done = true;
|
||||
}
|
||||
|
||||
if (done || Date.now() - started > CIS4_LOAD_TIMEOUT) {
|
||||
this.stopCis4Poll();
|
||||
this.cis4Loading = false;
|
||||
}
|
||||
}, 120);
|
||||
},
|
||||
|
||||
stopCis4Poll() {
|
||||
if (this.cis4Poll) {
|
||||
clearInterval(this.cis4Poll);
|
||||
this.cis4Poll = null;
|
||||
}
|
||||
},
|
||||
|
||||
neuLaden() {
|
||||
if (this.$refs.previewFrame) {
|
||||
this.$refs.previewFrame.src = this.previewUrl + '&t=' + Date.now();
|
||||
}
|
||||
if (this.$refs.cis4Frame) {
|
||||
this.startCis4Load();
|
||||
this.$refs.cis4Frame.src = this.cis4PreviewUrl + '&t=' + Date.now();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// On the window, not the document: HorizontalSplit ends the drag from a listener
|
||||
// it puts on the window, and a mouseup aimed at the window never reaches a
|
||||
// listener on the document. The window is the first node of the capture path, so
|
||||
// it sees everything the document would and that case as well.
|
||||
window.addEventListener('mousedown', this.onSplitterDown, true);
|
||||
window.addEventListener('mouseup', this.onSplitterUp, true);
|
||||
// Backstop. A missed mouseup would leave the frame unclickable.
|
||||
window.addEventListener('blur', this.onSplitterUp);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('mousedown', this.onSplitterDown, true);
|
||||
window.removeEventListener('mouseup', this.onSplitterUp, true);
|
||||
window.removeEventListener('blur', this.onSplitterUp);
|
||||
this.stopCis4Poll();
|
||||
},
|
||||
template: `
|
||||
<div class="mt-3" :class="{ 'cms-preview-dragging': dragging }">
|
||||
<div v-if="sichtbar === false" class="text-danger fw-bold mb-2">
|
||||
{{ $p.t('cms/unsichtbarImLivesystem') }}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<a :href="cis4Url" target="_blank">{{ $p.t('cms/inCis4Ansehen') }}</a>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn"
|
||||
:class="mode === 'legacy' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@click="selectMode('legacy')"
|
||||
>{{ $p.t('cms/vorschauLegacy') }}</button>
|
||||
<button type="button" class="btn"
|
||||
:class="mode === 'cis4' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@click="selectMode('cis4')"
|
||||
>{{ $p.t('cms/vorschauCis4') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'cis4' && !isRedirect"
|
||||
class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn"
|
||||
:class="cis4Width === 'desktop' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
:title="$p.t('cms/breiteDesktop')"
|
||||
@click="selectWidth('desktop')"
|
||||
><i class="fa-solid fa-display"></i></button>
|
||||
<button type="button" class="btn"
|
||||
:class="cis4Width === 'mobile' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
:title="$p.t('cms/breiteMobil')"
|
||||
@click="selectWidth('mobile')"
|
||||
><i class="fa-solid fa-mobile-screen-button"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="mode === 'legacy'">
|
||||
<div v-if="hasContentcomponents" class="alert alert-info py-2">
|
||||
{{ $p.t('cms/contentcomponentsNurCis4') }}
|
||||
</div>
|
||||
<template v-if="isRedirect">
|
||||
<a :href="previewUrl" target="_blank">
|
||||
{{ $p.t('cms/vorschauInEigenemFenster') }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<iframe
|
||||
ref="previewFrame"
|
||||
:src="previewUrl"
|
||||
style="width: 100%; min-height: 500px; border: 1px solid #ccc;"
|
||||
></iframe>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!hasCis4Stylesheet" class="alert alert-warning py-2">
|
||||
{{ $p.t('cms/keinCis4Stylesheet') }}
|
||||
</div>
|
||||
<template v-if="isRedirect">
|
||||
<a :href="cis4PreviewUrl" target="_blank">
|
||||
{{ $p.t('cms/vorschauInEigenemFenster') }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="position-relative border" :style="cis4WrapStyle">
|
||||
<iframe
|
||||
ref="cis4Frame"
|
||||
:src="cis4PreviewUrl"
|
||||
@load="onCis4Load"
|
||||
style="width: 100%; min-height: 500px; border: 0;
|
||||
display: block;"
|
||||
></iframe>
|
||||
<div v-if="cis4Loading"
|
||||
class="position-absolute top-0 start-0 w-100 h-100 d-flex
|
||||
flex-column align-items-center justify-content-center"
|
||||
style="background: rgba(255, 255, 255, 0.85);">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{ $p.t('ui/loading') }}</span>
|
||||
</div>
|
||||
<div class="mt-2 text-muted small">
|
||||
{{ $p.t('cms/vorschauLaedt') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,153 +0,0 @@
|
||||
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>
|
||||
`
|
||||
};
|
||||
@@ -1,199 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
import XsdForm from '../Form/XsdForm.js';
|
||||
import CmsPreview from '../Preview.js';
|
||||
import { CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js';
|
||||
|
||||
export default {
|
||||
name: 'CmsContent',
|
||||
components: {
|
||||
'xsd-form': XsdForm,
|
||||
'cms-preview': CmsPreview
|
||||
},
|
||||
props: {
|
||||
contentId: Number,
|
||||
sprache: String,
|
||||
version: Number,
|
||||
contentInfo: Object
|
||||
},
|
||||
emits: ['reload-content-info', 'refresh-tree-node', 'select-content'],
|
||||
data() {
|
||||
return {
|
||||
schema: null,
|
||||
values: {},
|
||||
sperre: null,
|
||||
sichtbar: true,
|
||||
loading: false,
|
||||
saving: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// The legacy renderer runs no Vue and draws nothing for a marker. The preview says
|
||||
// so, but only while the legacy view is the one on screen.
|
||||
hasContentcomponents() {
|
||||
return Object.values(this.values || {}).some(
|
||||
value => typeof value === 'string' && value.indexOf(CONTENTCOMPONENT_ATTR) !== -1
|
||||
);
|
||||
},
|
||||
// The redirect template carries a URL, and its preview opens in a new window
|
||||
// instead of an iframe. So without the lock nothing on this tab shows the values.
|
||||
// Read only fields spare the editor a lock that blocks the content for 24 hours.
|
||||
isRedirect() {
|
||||
return this.contentInfo?.template_kurzbz === 'redirect';
|
||||
},
|
||||
lockState() {
|
||||
if (!this.sperre || this.sperre.gesperrt_uid === null) return 'free';
|
||||
if (this.sperre.own) return 'own';
|
||||
if (this.sperre.expired) return 'expired';
|
||||
return 'foreign';
|
||||
},
|
||||
lockInfo() {
|
||||
if (!this.sperre || this.sperre.gesperrt_uid === null) return '';
|
||||
|
||||
const start = this.formatDateTime(this.sperre.start);
|
||||
const ende = this.formatDateTime(this.sperre.expires);
|
||||
|
||||
if (this.sperre.own)
|
||||
return this.$p.t('cms/eigeneSperre', [start, ende]);
|
||||
|
||||
const uid = this.sperre.gesperrt_uid;
|
||||
if (this.sperre.expired)
|
||||
return this.$p.t('cms/gesperrtVonSeitAbgelaufen', [uid, start, ende]);
|
||||
return this.$p.t('cms/gesperrtVonSeit', [uid, start, ende]);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
contentId() { this.loadFormData(); },
|
||||
sprache() { this.loadFormData(); },
|
||||
version() { this.loadFormData(); }
|
||||
},
|
||||
methods: {
|
||||
formatDateTime(dateStr) {
|
||||
if (!dateStr) return this.$p.t('cms/unbekannt');
|
||||
const dt = luxon.DateTime.fromSQL(dateStr);
|
||||
return dt.isValid ? dt.toFormat('dd.MM.yyyy HH:mm') : dateStr;
|
||||
},
|
||||
|
||||
loadFormData() {
|
||||
if (this.contentId == null || !this.sprache || !this.version) return;
|
||||
this.loading = true;
|
||||
Promise.all([
|
||||
this.$api.call(ApiCmsAdmin.getFormData(this.contentId, this.sprache, this.version)),
|
||||
this.$api.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
|
||||
])
|
||||
.then(([formResult, spracheResult]) => {
|
||||
const d = formResult.data;
|
||||
this.schema = d.schema;
|
||||
this.values = d.values || {};
|
||||
this.sperre = d.sperre;
|
||||
this.sichtbar = !!spracheResult.data.sichtbar;
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError)
|
||||
.finally(() => { this.loading = false; });
|
||||
},
|
||||
|
||||
lock() {
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.postLock(this.sperre.contentsprache_id))
|
||||
.then(() => { this.loadFormData(); })
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
unlock() {
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.deleteLock(this.sperre.contentsprache_id))
|
||||
.then(() => { this.loadFormData(); })
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
forceUnlock() {
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.deleteLockForced(this.sperre.contentsprache_id))
|
||||
.then(() => { this.loadFormData(); })
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
save() {
|
||||
const freshValues = this.$refs.xsdForm
|
||||
? this.$refs.xsdForm.collectValues()
|
||||
: this.values;
|
||||
this.saving = true;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.putFormData(
|
||||
this.contentId, this.sprache, this.version, freshValues
|
||||
))
|
||||
.then(() => {
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/gespeichert'));
|
||||
// The form holds the title, and the tree shows it.
|
||||
this.$emit('refresh-tree-node', this.contentId);
|
||||
if (this.$refs.preview) this.$refs.preview.neuLaden();
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError)
|
||||
.finally(() => { this.saving = false; });
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadFormData();
|
||||
},
|
||||
template: `
|
||||
<div class="p-3" v-if="contentId != null">
|
||||
<template v-if="!loading && sperre">
|
||||
<div v-if="sperre.gesperrt_uid !== null"
|
||||
class="alert"
|
||||
:class="lockState === 'expired' ? 'alert-warning' : 'alert-info'">
|
||||
{{ lockInfo }}
|
||||
</div>
|
||||
|
||||
<template v-if="lockState === 'free'">
|
||||
<button class="btn btn-primary mb-3" @click="lock">
|
||||
{{ $p.t('cms/zurBearbeitungSperren') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="lockState === 'own'">
|
||||
<button class="btn btn-outline-secondary mb-3" @click="unlock">
|
||||
{{ $p.t('cms/sperreFreigeben') }}
|
||||
</button>
|
||||
<xsd-form
|
||||
ref="xsdForm"
|
||||
:schema="schema"
|
||||
v-model="values"
|
||||
:disabled="false"
|
||||
></xsd-form>
|
||||
<button class="btn btn-primary mt-2 mb-3" @click="save" :disabled="saving">
|
||||
{{ $p.t('cms/speichern') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="lockState === 'expired'">
|
||||
<button class="btn btn-warning mb-3" @click="lock">
|
||||
{{ $p.t('cms/sperreUebernehmen') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button v-if="sperre.may_force" class="btn btn-danger mb-3" @click="forceUnlock">
|
||||
{{ $p.t('cms/freigabeErzwingen') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<xsd-form
|
||||
v-if="isRedirect && lockState !== 'own'"
|
||||
:schema="schema"
|
||||
:modelValue="values"
|
||||
:disabled="true"
|
||||
></xsd-form>
|
||||
|
||||
<cms-preview
|
||||
ref="preview"
|
||||
:content-id="contentId"
|
||||
:sprache="sprache"
|
||||
:version="version"
|
||||
:template-kurzbz="contentInfo?.template_kurzbz"
|
||||
:has-cis4-stylesheet="contentInfo?.has_cis4_stylesheet !== false"
|
||||
:has-contentcomponents="hasContentcomponents"
|
||||
:sichtbar="sichtbar"
|
||||
></cms-preview>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
|
||||
export default {
|
||||
name: 'CmsDelete',
|
||||
props: {
|
||||
contentId: Number,
|
||||
sprache: String,
|
||||
version: Number,
|
||||
contentInfo: Object
|
||||
},
|
||||
emits: ['reload-content-info', 'reload-tree', 'refresh-tree-node', 'select-content'],
|
||||
data() {
|
||||
return {
|
||||
usages: [],
|
||||
loadingUsage: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasRestrict() {
|
||||
return this.usages.some(u => u.table !== 'campus.tbl_infoscreen_content');
|
||||
},
|
||||
// forward defaultLanguage prop to tabs. Using 'German' as fallback.
|
||||
isLastVersion() {
|
||||
if (!this.contentInfo?.versions || !this.sprache) return false;
|
||||
const defaultLang = 'German';
|
||||
const versions = this.contentInfo.versions[this.sprache];
|
||||
return this.sprache === defaultLang && versions && versions.length === 1;
|
||||
},
|
||||
totalVersionCount() {
|
||||
if (!this.contentInfo?.versions) return 0;
|
||||
return Object.values(this.contentInfo.versions)
|
||||
.reduce((sum, vers) => sum + vers.length, 0);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
contentId() { this.loadUsage(); }
|
||||
},
|
||||
methods: {
|
||||
loadUsage() {
|
||||
if (this.contentId == null) return;
|
||||
this.loadingUsage = true;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getUsage(this.contentId))
|
||||
.then(result => {
|
||||
this.usages = result.data || [];
|
||||
})
|
||||
.finally(() => { this.loadingUsage = false; });
|
||||
},
|
||||
|
||||
deleteVersion() {
|
||||
this.$fhcAlert.confirm({
|
||||
message: this.$p.t('cms/versionLoeschenBestaetigen'),
|
||||
acceptLabel: this.$p.t('ui/loeschen'),
|
||||
acceptClass: 'p-button-danger'
|
||||
}).then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
return this.$api.call(
|
||||
ApiCmsAdmin.deleteContentsprache(this.contentId, this.sprache, this.version)
|
||||
);
|
||||
}).then(result => {
|
||||
if (!result) return;
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/versionGeloescht'));
|
||||
this.$emit('reload-content-info');
|
||||
// The tree shows the title of the newest version, so the label can change.
|
||||
this.$emit('refresh-tree-node', this.contentId);
|
||||
}).catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
deleteContent() {
|
||||
this.$fhcAlert.confirm({
|
||||
message: this.$p.t('cms/contentLoeschenBestaetigen', [
|
||||
this.contentInfo.languages.length,
|
||||
this.totalVersionCount
|
||||
]),
|
||||
acceptLabel: this.$p.t('ui/loeschen'),
|
||||
acceptClass: 'p-button-danger'
|
||||
}).then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
return this.$api.call(ApiCmsAdmin.deleteContent(this.contentId));
|
||||
}).then(result => {
|
||||
if (!result) return;
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/contentGeloescht'));
|
||||
// Children of the deleted entry can reappear under another parent, so the
|
||||
// whole tree has to be rebuilt here.
|
||||
this.$emit('reload-tree');
|
||||
this.$nextTick(() => {
|
||||
this.$emit('select-content', null);
|
||||
});
|
||||
}).catch(this.$fhcAlert.handleSystemError);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadUsage();
|
||||
},
|
||||
// Five tables prevent the deletion with ON DELETE RESTRICT. tbl_infoscreen_content
|
||||
// deletes its own rows with CASCADE. The legacy code shows neither case and hides the
|
||||
// error. See Q5 in the contract.
|
||||
template: `
|
||||
<div class="p-3" v-if="contentId != null">
|
||||
<h5>{{ $p.t('cms/dieseVersionLoeschen') }}</h5>
|
||||
|
||||
<div v-if="isLastVersion" class="alert alert-warning mb-3">
|
||||
{{ $p.t('cms/letzteVersionNichtLoeschbar') }}
|
||||
</div>
|
||||
<div v-else class="mb-3">
|
||||
<button class="btn btn-outline-danger" @click="deleteVersion">
|
||||
{{ $p.t('cms/dieseVersionLoeschen') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5>{{ $p.t('cms/ganzenContentLoeschen') }}</h5>
|
||||
|
||||
<div v-if="loadingUsage" class="mb-3">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="usages.length === 0" class="alert alert-info mb-3">
|
||||
{{ $p.t('cms/keineVerwendungGefunden') }}
|
||||
</div>
|
||||
<div v-else class="mb-3">
|
||||
<div class="alert alert-warning">
|
||||
{{ $p.t('cms/contentWirdVerwendetIn') }}
|
||||
</div>
|
||||
<ul class="list-group mb-3">
|
||||
<li v-for="(u, i) in usages" :key="i"
|
||||
class="list-group-item d-flex justify-content-between
|
||||
align-items-center gap-3">
|
||||
<span class="me-auto">
|
||||
<span class="d-block">{{ u.label || '—' }}</span>
|
||||
<!-- The value alone says nothing about where the entry
|
||||
sits. The table is a database name and stays as it is. -->
|
||||
<code class="small text-muted">{{ u.table }}</code>
|
||||
</span>
|
||||
<span v-if="u.table === 'campus.tbl_infoscreen_content'"
|
||||
class="badge bg-danger text-nowrap">
|
||||
{{ $p.t('cms/wirdMitgeloescht') }}
|
||||
</span>
|
||||
<span v-else class="badge bg-warning text-dark text-nowrap">
|
||||
{{ $p.t('cms/verhindertLoeschen') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button class="btn btn-danger" @click="deleteContent"
|
||||
:disabled="hasRestrict || loadingUsage">
|
||||
{{ $p.t('cms/ganzenContentLoeschen') }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,332 +0,0 @@
|
||||
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,65 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
|
||||
export default {
|
||||
name: 'CmsHistory',
|
||||
props: {
|
||||
contentId: Number,
|
||||
sprache: String,
|
||||
version: Number,
|
||||
contentInfo: Object
|
||||
},
|
||||
emits: ['reload-content-info', 'reload-tree', 'select-content'],
|
||||
data() {
|
||||
return {
|
||||
versions: []
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
contentId() { this.loadVersions(); },
|
||||
sprache() { this.loadVersions(); }
|
||||
},
|
||||
methods: {
|
||||
loadVersions() {
|
||||
if (this.contentId == null || !this.sprache) return;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getVersions(this.contentId, this.sprache))
|
||||
.then(result => {
|
||||
this.versions = result.data || [];
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const dt = luxon.DateTime.fromSQL(dateStr);
|
||||
return dt.isValid ? dt.toFormat('dd.MM.yyyy') : dateStr;
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadVersions();
|
||||
},
|
||||
template: `
|
||||
<div class="p-3" v-if="contentId != null">
|
||||
<h5>{{ $p.t('cms/versionen') }}</h5>
|
||||
|
||||
<div v-for="v in versions" :key="v.version"
|
||||
class="mb-3 p-2 border rounded"
|
||||
:class="{ 'border-primary border-2': v.version === version }">
|
||||
<div class="fw-bold">
|
||||
{{ $p.t('cms/version') }} {{ v.version }}
|
||||
<span v-if="!v.sichtbar" class="badge bg-secondary ms-2">
|
||||
{{ $p.t('cms/unsichtbar') }}
|
||||
</span>
|
||||
</div>
|
||||
<div>{{ $p.t('cms/erstelltAmVon', [formatDate(v.insertamum), v.insertvon]) }}</div>
|
||||
<div v-if="v.updatevon">
|
||||
{{ $p.t('cms/letzteAenderungVonAm', [formatDate(v.updateamum), v.updatevon]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
|
||||
export default {
|
||||
name: 'CmsPermissions',
|
||||
props: {
|
||||
contentId: Number,
|
||||
sprache: String,
|
||||
version: Number,
|
||||
contentInfo: Object
|
||||
},
|
||||
emits: ['reload-content-info', 'patch-tree-node', 'select-content'],
|
||||
data() {
|
||||
return {
|
||||
gruppen: [],
|
||||
allGruppen: [],
|
||||
selectedGruppe: '',
|
||||
allGruppenLoaded: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
availableGruppen() {
|
||||
const assigned = new Set(this.gruppen.map(g => g.gruppe_kurzbz));
|
||||
return this.allGruppen.filter(g => !assigned.has(g.gruppe_kurzbz));
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
contentId() { this.loadGruppen(); }
|
||||
},
|
||||
methods: {
|
||||
loadGruppen() {
|
||||
if (this.contentId == null) return Promise.resolve();
|
||||
return this.$api
|
||||
.call(ApiCmsAdmin.getGruppen(this.contentId))
|
||||
.then(result => {
|
||||
this.gruppen = result.data || [];
|
||||
this.selectedGruppe = '';
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
// A group only draws the lock icon. It never moves a node, so the tree needs no
|
||||
// request here.
|
||||
patchTree() {
|
||||
this.$emit('patch-tree-node', {
|
||||
content_id: this.contentId,
|
||||
patch: { groups: this.gruppen.map(g => g.gruppe_kurzbz) }
|
||||
});
|
||||
},
|
||||
|
||||
loadAllGruppen() {
|
||||
if (this.allGruppenLoaded) return;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getAllGruppen())
|
||||
.then(result => {
|
||||
this.allGruppen = result.data || [];
|
||||
this.allGruppenLoaded = true;
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
async addGruppe() {
|
||||
if (!this.selectedGruppe) return;
|
||||
try {
|
||||
await this.$api.call(
|
||||
ApiCmsAdmin.postGruppe(this.contentId, this.selectedGruppe)
|
||||
);
|
||||
await this.loadGruppen();
|
||||
this.patchTree();
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/gruppeHinzugefuegt'));
|
||||
} catch (e) {
|
||||
this.$fhcAlert.handleSystemError(e);
|
||||
}
|
||||
},
|
||||
|
||||
async removeGruppe(gruppe_kurzbz) {
|
||||
if (this.gruppen.length <= 1) {
|
||||
const confirmed = await this.$fhcAlert.confirm({
|
||||
message: this.$p.t('cms/letzteGruppeEntfernenBestaetigen')
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
try {
|
||||
await this.$api.call(
|
||||
ApiCmsAdmin.deleteGruppe(this.contentId, gruppe_kurzbz)
|
||||
);
|
||||
await this.loadGruppen();
|
||||
this.patchTree();
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/gruppeEntfernt'));
|
||||
} catch (e) {
|
||||
this.$fhcAlert.handleSystemError(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadAllGruppen();
|
||||
this.loadGruppen();
|
||||
},
|
||||
template: `
|
||||
<div class="p-3" v-if="contentId != null">
|
||||
<template v-if="gruppen.length > 0">
|
||||
<h5>{{ $p.t('cms/folgendeGruppenDuerfenAnsehen') }}</h5>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $p.t('cms/gruppe') }}</th>
|
||||
<th>{{ $p.t('cms/bezeichnung') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="g in gruppen" :key="g.gruppe_kurzbz">
|
||||
<td>{{ g.gruppe_kurzbz }}</td>
|
||||
<td>{{ g.bezeichnung }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@click="removeGruppe(g.gruppe_kurzbz)">
|
||||
{{ $p.t('cms/entfernen') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<div v-else class="alert alert-warning">
|
||||
{{ $p.t('cms/seiteDarfVonAllenAngezeigtWerden') }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 align-items-end mt-3">
|
||||
<select class="form-select" v-model="selectedGruppe" style="max-width: 400px;">
|
||||
<option value="" disabled></option>
|
||||
<option v-for="g in availableGruppen" :key="g.gruppe_kurzbz"
|
||||
:value="g.gruppe_kurzbz">
|
||||
{{ g.gruppe_kurzbz }} — {{ g.bezeichnung }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" @click="addGruppe" :disabled="!selectedGruppe">
|
||||
{{ $p.t('cms/hinzufuegen') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
|
||||
export default {
|
||||
name: 'CmsProperties',
|
||||
props: {
|
||||
contentId: Number,
|
||||
sprache: String,
|
||||
version: Number,
|
||||
contentInfo: Object
|
||||
},
|
||||
emits: ['reload-content-info', 'refresh-tree-node', 'select-content'],
|
||||
data() {
|
||||
return {
|
||||
titel: '',
|
||||
sichtbar: false,
|
||||
template_kurzbz: '',
|
||||
oe_kurzbz: '',
|
||||
aktiv: false,
|
||||
menu_open: false,
|
||||
beschreibung: '',
|
||||
templates: [],
|
||||
organisationseinheiten: [],
|
||||
templateChanged: false,
|
||||
saving: false
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
contentId() { this.loadData(); },
|
||||
sprache() { this.loadData(); },
|
||||
version() { this.loadData(); },
|
||||
contentInfo() { this.loadContentFields(); }
|
||||
},
|
||||
methods: {
|
||||
loadData() {
|
||||
this.templateChanged = false;
|
||||
if (this.contentId == null || !this.sprache || !this.version) return;
|
||||
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
|
||||
.then(result => {
|
||||
const d = result.data;
|
||||
this.titel = d.titel || '';
|
||||
this.sichtbar = !!d.sichtbar;
|
||||
});
|
||||
|
||||
this.loadContentFields();
|
||||
},
|
||||
|
||||
loadContentFields() {
|
||||
if (!this.contentInfo) return;
|
||||
this.template_kurzbz = this.contentInfo.template_kurzbz || '';
|
||||
this.oe_kurzbz = this.contentInfo.oe_kurzbz || '';
|
||||
this.aktiv = !!this.contentInfo.aktiv;
|
||||
this.menu_open = !!this.contentInfo.menu_open;
|
||||
this.beschreibung = this.contentInfo.beschreibung || '';
|
||||
},
|
||||
|
||||
loadLookups() {
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getTemplates())
|
||||
.then(result => { this.templates = result.data || []; });
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getOrganisationseinheiten())
|
||||
.then(result => { this.organisationseinheiten = result.data || []; });
|
||||
},
|
||||
|
||||
onTemplateChange() {
|
||||
this.templateChanged = true;
|
||||
},
|
||||
|
||||
oeLabel(oe) {
|
||||
let label = oe.organisationseinheittyp_kurzbz + ' ' + oe.bezeichnung;
|
||||
if (!oe.aktiv) label += ' (' + this.$p.t('cms/inaktiv') + ')';
|
||||
return label;
|
||||
},
|
||||
|
||||
save() {
|
||||
this.saving = true;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.putProperties(
|
||||
this.contentId, this.sprache, this.version,
|
||||
this.template_kurzbz, this.oe_kurzbz,
|
||||
this.aktiv, this.menu_open, this.beschreibung,
|
||||
this.titel, this.sichtbar
|
||||
))
|
||||
.then(() => {
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/gespeichert'));
|
||||
this.$emit('reload-content-info');
|
||||
// Title, active flag and organisational unit all show in the tree.
|
||||
this.$emit('refresh-tree-node', this.contentId);
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError)
|
||||
.finally(() => { this.saving = false; });
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadLookups();
|
||||
this.loadData();
|
||||
},
|
||||
template: `
|
||||
<div class="p-3" v-if="contentId != null">
|
||||
<h5>{{ $p.t('cms/giltFuerAlleSprachen') }}</h5>
|
||||
<div class="mb-3 row">
|
||||
<label class="col-sm-3 col-form-label">{{ $p.t('cms/vorlage') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-select" v-model="template_kurzbz"
|
||||
@change="onTemplateChange">
|
||||
<option v-for="t in templates" :key="t.template_kurzbz"
|
||||
:value="t.template_kurzbz">{{ t.bezeichnung }}</option>
|
||||
</select>
|
||||
<div v-if="templateChanged" class="text-danger mt-1 fw-bold">
|
||||
{{ $p.t('cms/vorlageWarnung') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 row">
|
||||
<label class="col-sm-3 col-form-label">{{ $p.t('cms/organisationseinheit') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-select" v-model="oe_kurzbz">
|
||||
<option v-for="oe in organisationseinheiten" :key="oe.oe_kurzbz"
|
||||
:value="oe.oe_kurzbz"
|
||||
:class="{ 'text-muted': !oe.aktiv }">{{ oeLabel(oe) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 row">
|
||||
<div class="col-sm-9 offset-sm-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" v-model="aktiv"
|
||||
id="prop-aktiv">
|
||||
<label class="form-check-label" for="prop-aktiv">
|
||||
{{ $p.t('cms/aktiv') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 row">
|
||||
<div class="col-sm-9 offset-sm-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" v-model="menu_open"
|
||||
id="prop-menu-open">
|
||||
<label class="form-check-label" for="prop-menu-open">
|
||||
{{ $p.t('cms/menuOpen') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 row">
|
||||
<label class="col-sm-3 col-form-label">{{ $p.t('cms/beschreibung') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<textarea class="form-control" rows="3" v-model="beschreibung"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h5>{{ $p.t('cms/giltFuerDieseVersion') }}</h5>
|
||||
<div class="mb-3 row">
|
||||
<label class="col-sm-3 col-form-label">{{ $p.t('cms/titel') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" v-model="titel" maxlength="256">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 row">
|
||||
<div class="col-sm-9 offset-sm-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" v-model="sichtbar"
|
||||
id="prop-sichtbar">
|
||||
<label class="form-check-label" for="prop-sichtbar">
|
||||
{{ $p.t('cms/sichtbar') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-primary" @click="save" :disabled="saving">
|
||||
{{ $p.t('cms/speichern') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,521 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
import TreeFilter, { emptyCriteria } from './TreeFilter.js';
|
||||
import MenuSwitch from './MenuSwitch.js';
|
||||
|
||||
// The meta dates are SQL timestamps. Format them like the history tab does.
|
||||
function formatDay(value) {
|
||||
if (!value) return '';
|
||||
const dt = luxon.DateTime.fromSQL(value);
|
||||
return dt.isValid ? dt.toFormat('dd.MM.yyyy') : value;
|
||||
}
|
||||
|
||||
// A day string of the meta value. The inputs deliver YYYY-MM-DD, so compare as text.
|
||||
function inRange(value, from, to) {
|
||||
if (!from && !to) return true;
|
||||
if (!value) return false;
|
||||
const day = value.substring(0, 10);
|
||||
if (from && day < from) return false;
|
||||
if (to && day > to) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The meta fields that hold a number. The rest hold an SQL timestamp.
|
||||
const NUMERIC_FIELDS = ['childcount', 'contentlength'];
|
||||
|
||||
// Sort key of one node. A content without meta sorts last in a descending list.
|
||||
function sortValue(meta, contentId, field) {
|
||||
const numeric = NUMERIC_FIELDS.indexOf(field) !== -1;
|
||||
const m = meta[contentId];
|
||||
if (!m) return numeric ? -1 : '';
|
||||
if (numeric) return m[field];
|
||||
return m[field] || '';
|
||||
}
|
||||
|
||||
const TreeNode = {
|
||||
name: 'CmsTreeNode',
|
||||
inject: ['treeSelect', 'treeToggle'],
|
||||
props: {
|
||||
node: Object,
|
||||
activeContentId: Number,
|
||||
expandedKeys: Object,
|
||||
flat: Boolean,
|
||||
meta: Object,
|
||||
metric: String,
|
||||
// Ancestors of this node, from the top down. Only the nodes of the flat result
|
||||
// list get one; a child inside the subtree sits under its parent already.
|
||||
ancestors: Array,
|
||||
// A node that is context rather than a hit. It and its children draw back.
|
||||
muted: Boolean
|
||||
},
|
||||
computed: {
|
||||
// Shows the value the active filter or sort works on, so the order is readable.
|
||||
badge() {
|
||||
if (!this.metric || !this.meta) return null;
|
||||
const m = this.meta[this.node.content_id];
|
||||
if (!m) return null;
|
||||
if (NUMERIC_FIELDS.indexOf(this.metric) !== -1) return String(m[this.metric]);
|
||||
return formatDay(m[this.metric]) || null;
|
||||
},
|
||||
badgeEmpty() {
|
||||
return NUMERIC_FIELDS.indexOf(this.metric) !== -1 && this.badge === '0';
|
||||
},
|
||||
hasChildren() {
|
||||
return this.node.children && this.node.children.length > 0;
|
||||
},
|
||||
isExpanded() {
|
||||
return !!this.expandedKeys[this.node.content_id];
|
||||
},
|
||||
isActive() {
|
||||
return this.activeContentId != null
|
||||
&& this.node.content_id === this.activeContentId;
|
||||
},
|
||||
// The hit keeps its place; the path above it steps in one level at a time.
|
||||
indent() {
|
||||
return this.ancestors ? this.ancestors.length : 0;
|
||||
},
|
||||
labelStyle() {
|
||||
const s = {};
|
||||
if (this.isActive) {
|
||||
s.fontWeight = 'bold';
|
||||
s.background = '#e8f0fe';
|
||||
s.borderRadius = '3px';
|
||||
s.padding = '0 4px';
|
||||
}
|
||||
if (!this.node.aktiv) {
|
||||
s.opacity = '0.5';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<li class="py-1" :class="{ 'cms-tree-context': muted }">
|
||||
<div v-for="(a, i) in ancestors" :key="a.content_id"
|
||||
class="cms-tree-ancestor"
|
||||
:style="{ marginLeft: (i * 1.1) + 'em' }">
|
||||
<!-- treeSelect reads entitled and content_id, so an ancestor row is a node
|
||||
as far as it is concerned. An ancestor of a unit the editor is not
|
||||
entitled for stays plain text, the same as in the tree itself. -->
|
||||
<a v-if="a.entitled" href="#"
|
||||
@click.prevent="treeSelect(a)"
|
||||
>{{ a.titel || a.content_id }} ({{ a.content_id }})</a>
|
||||
<span v-else>{{ a.titel || a.content_id }} ({{ a.content_id }})</span>
|
||||
</div>
|
||||
<div :style="indent ? { marginLeft: (indent * 1.1) + 'em' } : null">
|
||||
<span v-if="!flat && hasChildren"
|
||||
style="cursor:pointer;user-select:none"
|
||||
@click="treeToggle(node)">
|
||||
<i class="fa-solid fa-fw"
|
||||
:class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
|
||||
</span>
|
||||
<span v-else-if="!flat" style="display:inline-block;width:1.25em"></span>
|
||||
<a v-if="node.entitled"
|
||||
href="#"
|
||||
class="text-decoration-none"
|
||||
:style="labelStyle"
|
||||
@click.prevent="treeSelect(node)">{{ node.titel }} ({{ node.content_id }})</a>
|
||||
<span v-else :style="!node.aktiv ? 'opacity:0.5' : ''">{{ node.titel }} ({{ node.content_id }})</span>
|
||||
<i v-if="node.groups && node.groups.length"
|
||||
class="fa-solid fa-lock fa-sm ms-1 cms-tree-lock"
|
||||
:title="node.groups.join(', ')"></i>
|
||||
<span v-if="badge !== null"
|
||||
class="badge ms-1"
|
||||
:class="badgeEmpty ? 'bg-light text-muted' : 'bg-secondary'">{{ badge }}</span>
|
||||
<ul v-if="!flat && isExpanded && hasChildren" class="list-unstyled ms-3">
|
||||
<cms-tree-node
|
||||
v-for="child in node.children"
|
||||
:key="child.content_id"
|
||||
:node="child"
|
||||
:active-content-id="activeContentId"
|
||||
:expanded-keys="expandedKeys"
|
||||
:flat="flat"
|
||||
:meta="meta"
|
||||
:metric="metric"
|
||||
:muted="muted || indent > 0"
|
||||
></cms-tree-node>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
`
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'CmsTree',
|
||||
components: {
|
||||
'cms-tree-node': TreeNode,
|
||||
'tree-filter': TreeFilter,
|
||||
'menu-switch': MenuSwitch
|
||||
},
|
||||
props: {
|
||||
activeContentId: Number
|
||||
},
|
||||
emits: ['select-content'],
|
||||
data() {
|
||||
const stored = localStorage.getItem('cms/menu');
|
||||
return {
|
||||
menu: (stored === 'content' || stored === 'news') ? stored : 'content',
|
||||
filter: '',
|
||||
nodes: [],
|
||||
loading: false,
|
||||
expandedKeys: {},
|
||||
criteria: emptyCriteria(),
|
||||
meta: null,
|
||||
metaLoading: false,
|
||||
// A flat list drops the context a generic title needs. The toggle puts the
|
||||
// way down to each hit back, and the choice survives a page switch.
|
||||
showHierarchy: localStorage.getItem('cms/treeHierarchie') === '1',
|
||||
ancestors: {},
|
||||
ancestorsLoading: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// The news menu is a flat list without the meta fields.
|
||||
advanced() {
|
||||
return this.menu === 'content';
|
||||
},
|
||||
// Every field but the template comes from the meta request.
|
||||
needsMeta() {
|
||||
const c = this.criteria;
|
||||
return c.mine
|
||||
|| !!c.updatedFrom || !!c.updatedTo
|
||||
|| !!c.createdFrom || !!c.createdTo
|
||||
|| c.minChildren !== '' || c.maxChildren !== ''
|
||||
|| c.minLength !== '' || c.maxLength !== '' || c.emptyOnly
|
||||
|| c.sort === 'updateamum' || c.sort === 'insertamum'
|
||||
|| c.sort === 'childcount' || c.sort === 'contentlength';
|
||||
},
|
||||
narrowing() {
|
||||
const c = this.criteria;
|
||||
return !!c.template_kurzbz || c.mine
|
||||
|| !!c.updatedFrom || !!c.updatedTo
|
||||
|| !!c.createdFrom || !!c.createdTo
|
||||
|| c.minChildren !== '' || c.maxChildren !== ''
|
||||
|| c.minLength !== '' || c.maxLength !== '' || c.emptyOnly;
|
||||
},
|
||||
active() {
|
||||
return this.advanced && (this.narrowing || this.criteria.sort !== '');
|
||||
},
|
||||
// A hierarchical tree already shows where a node sits. Only a flat list, from a
|
||||
// search or from a filter, needs the path spelled out.
|
||||
isFlat() {
|
||||
return this.active || this.menu === 'news' || !!this.filter;
|
||||
},
|
||||
showPaths() {
|
||||
return this.showHierarchy && this.isFlat;
|
||||
},
|
||||
// The value the badge shows. The filter drives it, the sort order falls back.
|
||||
metric() {
|
||||
const c = this.criteria;
|
||||
if (!this.active) return null;
|
||||
if (c.emptyOnly || c.minLength !== '' || c.maxLength !== '') return 'contentlength';
|
||||
if (c.minChildren !== '' || c.maxChildren !== '') return 'childcount';
|
||||
if (c.updatedFrom || c.updatedTo) return 'updateamum';
|
||||
if (c.createdFrom || c.createdTo) return 'insertamum';
|
||||
if (c.sort !== '' && c.sort !== 'titel') return c.sort;
|
||||
return null;
|
||||
},
|
||||
// Flat on purpose: a match sits at any depth, so per-level sorting would hide it.
|
||||
visibleNodes() {
|
||||
if (!this.active) return this.nodes;
|
||||
|
||||
const flatList = [];
|
||||
const walk = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
flatList.push(node);
|
||||
if (node.children && node.children.length) walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(this.nodes);
|
||||
|
||||
return this.sortNodes(flatList.filter(node => this.matches(node)));
|
||||
}
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
treeSelect: (node) => { this.selectNode(node); },
|
||||
treeToggle: (node) => { this.toggleNode(node); }
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
menu() {
|
||||
this.loadTree();
|
||||
},
|
||||
activeContentId(id) {
|
||||
if (id != null) {
|
||||
this.expandPathTo(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Keeps the expanded branches. A reload must not fold the tree back up.
|
||||
loadTree() {
|
||||
this.loading = true;
|
||||
return this.$api
|
||||
.call(ApiCmsAdmin.getTree(this.menu, this.filter))
|
||||
.then(result => {
|
||||
this.nodes = result.data;
|
||||
if (this.activeContentId != null) {
|
||||
this.expandPathTo(this.activeContentId);
|
||||
}
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError)
|
||||
.finally(() => { this.loading = false; });
|
||||
},
|
||||
|
||||
// --- Public: the parent calls these instead of rebuilding the component ---
|
||||
|
||||
reload() {
|
||||
// A change moves a date or a child count. Drop the cached meta.
|
||||
this.meta = null;
|
||||
if (this.needsMeta) this.loadMeta();
|
||||
return this.loadTree();
|
||||
},
|
||||
|
||||
// Replaces one branch. Use it after a change to the children, the title, the
|
||||
// active flag or the organisational unit of that content.
|
||||
refreshNode(contentId) {
|
||||
// The news menu is a flat list of its own. Only a full reload fits it.
|
||||
if (this.menu === 'news' || contentId == null) return this.loadTree();
|
||||
|
||||
const slot = this.findSlot(this.nodes, contentId);
|
||||
// Not in the tree, so the change may add a new root. Rebuild.
|
||||
if (slot === null) return this.loadTree();
|
||||
|
||||
return this.$api
|
||||
.call(ApiCmsAdmin.getSubtree(contentId))
|
||||
.then(result => {
|
||||
if (result.data) {
|
||||
slot.list.splice(slot.index, 1, result.data);
|
||||
} else {
|
||||
slot.list.splice(slot.index, 1);
|
||||
}
|
||||
if (this.activeContentId != null) {
|
||||
this.expandPathTo(this.activeContentId);
|
||||
}
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
|
||||
// Writes known fields straight into one node. Groups do not change the structure,
|
||||
// so they need no request.
|
||||
patchNode(contentId, patch) {
|
||||
const slot = this.findSlot(this.nodes, contentId);
|
||||
if (slot === null) return;
|
||||
Object.assign(slot.node, patch);
|
||||
},
|
||||
|
||||
findSlot(nodes, contentId) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].content_id === contentId) {
|
||||
return { list: nodes, index: i, node: nodes[i] };
|
||||
}
|
||||
if (nodes[i].children && nodes[i].children.length) {
|
||||
const found = this.findSlot(nodes[i].children, contentId);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
expandPathTo(targetId) {
|
||||
const search = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.content_id === targetId) return true;
|
||||
if (node.children && node.children.length) {
|
||||
if (search(node.children)) {
|
||||
this.expandedKeys[node.content_id] = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
search(this.nodes);
|
||||
},
|
||||
toggleNode(node) {
|
||||
if (this.expandedKeys[node.content_id]) {
|
||||
delete this.expandedKeys[node.content_id];
|
||||
} else {
|
||||
this.expandedKeys[node.content_id] = true;
|
||||
}
|
||||
},
|
||||
selectNode(node) {
|
||||
if (node.entitled) {
|
||||
this.$emit('select-content', node.content_id);
|
||||
}
|
||||
},
|
||||
matches(node) {
|
||||
const c = this.criteria;
|
||||
|
||||
if (c.template_kurzbz && node.template_kurzbz !== c.template_kurzbz) return false;
|
||||
if (!this.needsMeta) return true;
|
||||
// The meta arrives after the tree. Hide nothing until it is there.
|
||||
if (this.meta === null) return true;
|
||||
|
||||
const m = this.meta[node.content_id];
|
||||
if (!m) return false;
|
||||
|
||||
if (c.mine && !m.mine) return false;
|
||||
if (!inRange(m.updateamum, c.updatedFrom, c.updatedTo)) return false;
|
||||
if (!inRange(m.insertamum, c.createdFrom, c.createdTo)) return false;
|
||||
if (c.minChildren !== '' && m.childcount < Number(c.minChildren)) return false;
|
||||
if (c.maxChildren !== '' && m.childcount > Number(c.maxChildren)) return false;
|
||||
if (c.emptyOnly && m.contentlength !== 0) return false;
|
||||
if (c.minLength !== '' && m.contentlength < Number(c.minLength)) return false;
|
||||
if (c.maxLength !== '' && m.contentlength > Number(c.maxLength)) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// Descending for the numbers and the dates: the largest and the newest come first.
|
||||
sortNodes(list) {
|
||||
const sort = this.criteria.sort;
|
||||
if (!sort) return list;
|
||||
|
||||
if (sort === 'titel') {
|
||||
return list.slice().sort(
|
||||
(a, b) => (a.titel || '').localeCompare(b.titel || '')
|
||||
);
|
||||
}
|
||||
|
||||
const meta = this.meta || {};
|
||||
return list.slice().sort((a, b) => {
|
||||
const va = sortValue(meta, a.content_id, sort);
|
||||
const vb = sortValue(meta, b.content_id, sort);
|
||||
if (va === vb) return 0;
|
||||
return va < vb ? 1 : -1;
|
||||
});
|
||||
},
|
||||
|
||||
// One request for the whole tree. Cache it, the filter changes often.
|
||||
loadMeta() {
|
||||
if (this.meta !== null || this.metaLoading) return;
|
||||
|
||||
this.metaLoading = true;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getTreeMeta())
|
||||
.then(result => { this.meta = result.data || {}; })
|
||||
.catch(this.$fhcAlert.handleSystemError)
|
||||
.finally(() => { this.metaLoading = false; });
|
||||
},
|
||||
|
||||
toggleHierarchy() {
|
||||
this.showHierarchy = !this.showHierarchy;
|
||||
localStorage.setItem('cms/treeHierarchie', this.showHierarchy ? '1' : '0');
|
||||
|
||||
if (!this.showHierarchy) return;
|
||||
|
||||
// The point of the toggle is the context around a hit, and the subtree is half
|
||||
// of it. A collapsed node would show the way down to the hit and nothing under
|
||||
// it. Opening the hits leaves every caret free to collapse again.
|
||||
for (const node of this.visibleNodes)
|
||||
this.expandedKeys[node.content_id] = true;
|
||||
|
||||
this.loadAncestors();
|
||||
},
|
||||
|
||||
// One request for the whole list. Asking per node would be a request per hit.
|
||||
loadAncestors() {
|
||||
if (!this.showPaths) return;
|
||||
|
||||
const ids = this.visibleNodes.map(node => node.content_id);
|
||||
if (!ids.length) {
|
||||
this.ancestors = {};
|
||||
return;
|
||||
}
|
||||
|
||||
this.ancestorsLoading = true;
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getAncestors(ids))
|
||||
.then(result => { this.ancestors = result.data || {}; })
|
||||
.catch(this.$fhcAlert.handleSystemError)
|
||||
.finally(() => { this.ancestorsLoading = false; });
|
||||
},
|
||||
|
||||
pathFor(contentId) {
|
||||
return this.showPaths ? (this.ancestors[contentId] || []) : null;
|
||||
},
|
||||
|
||||
onCriteria(criteria) {
|
||||
this.criteria = criteria;
|
||||
if (this.needsMeta) this.loadMeta();
|
||||
this.loadAncestors();
|
||||
},
|
||||
|
||||
onFilter(text) {
|
||||
this.filter = text;
|
||||
this.loadTree().then(() => { this.loadAncestors(); });
|
||||
},
|
||||
createEntry(parentId) {
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.postContent(parentId))
|
||||
.then(result => {
|
||||
const newId = result.data.content_id;
|
||||
this.$fhcAlert.alertSuccess(this.$p.t('cms/eintragAngelegt'));
|
||||
// A new root is not under any branch, so only a reload shows it.
|
||||
const refreshed = parentId == null
|
||||
? this.loadTree()
|
||||
: this.refreshNode(parentId);
|
||||
// Select after the node exists, or expandPathTo finds nothing.
|
||||
return refreshed.then(() => {
|
||||
this.$emit('select-content', newId);
|
||||
});
|
||||
})
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadTree();
|
||||
},
|
||||
template: `
|
||||
<div class="p-2">
|
||||
<menu-switch v-model="menu"></menu-switch>
|
||||
<tree-filter class="mt-2"
|
||||
:advanced="advanced"
|
||||
@filter="onFilter"
|
||||
@criteria="onCriteria"></tree-filter>
|
||||
<div class="mt-2 mb-2">
|
||||
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||
@click="createEntry(null)">
|
||||
{{ $p.t('cms/neuenEintragHinzufuegen') }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
:disabled="activeContentId == null"
|
||||
@click="createEntry(activeContentId)">
|
||||
{{ $p.t('cms/neuenChildEintragHinzufuegen') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isFlat && !loading" class="d-flex align-items-center gap-2 mb-2">
|
||||
<div v-if="active" class="form-text m-0">
|
||||
<i v-if="metaLoading" class="fa-solid fa-spinner fa-spin me-1"></i>
|
||||
{{ $p.t('cms/nTreffer', [visibleNodes.length]) }}
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn btn-sm ms-auto"
|
||||
:class="showHierarchy ? 'btn-secondary' : 'btn-outline-secondary'"
|
||||
@click="toggleHierarchy">
|
||||
<i class="fa-solid fa-fw"
|
||||
:class="ancestorsLoading ? 'fa-spinner fa-spin' : 'fa-sitemap'"></i>
|
||||
{{ $p.t('cms/hierarchieAnzeigen') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loading" class="text-center text-muted py-3">
|
||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
<div v-else-if="visibleNodes?.length === 0" class="text-muted py-2">
|
||||
{{ (filter || active) ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }}
|
||||
</div>
|
||||
<ul v-else class="list-unstyled mb-0">
|
||||
<cms-tree-node
|
||||
v-for="node in visibleNodes"
|
||||
:key="node.content_id"
|
||||
:node="node"
|
||||
:active-content-id="activeContentId"
|
||||
:expanded-keys="expandedKeys"
|
||||
:flat="isFlat && !showPaths"
|
||||
:meta="meta"
|
||||
:metric="metric"
|
||||
:ancestors="pathFor(node.content_id)"
|
||||
></cms-tree-node>
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
export default {
|
||||
name: 'CmsMenuSwitch',
|
||||
props: {
|
||||
modelValue: { type: String, default: 'content' }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
methods: {
|
||||
select(value) {
|
||||
localStorage.setItem('cms/menu', value);
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// The legacy code keeps this choice in $_SESSION['cms/menu']. The Vue client uses
|
||||
// localStorage. The effect for the editor is the same.
|
||||
const stored = localStorage.getItem('cms/menu');
|
||||
if ((stored === 'content' || stored === 'news') && stored !== this.modelValue) {
|
||||
this.$emit('update:modelValue', stored);
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<button type="button" class="btn"
|
||||
:class="modelValue === 'content' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@click="select('content')">
|
||||
{{ $p.t('cms/menuContent') }}
|
||||
</button>
|
||||
<button type="button" class="btn"
|
||||
:class="modelValue === 'news' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@click="select('news')">
|
||||
{{ $p.t('cms/menuNews') }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
|
||||
|
||||
// Empty means "do not narrow by this field". Keep the inputs as strings, the tree parses.
|
||||
function emptyCriteria() {
|
||||
return {
|
||||
template_kurzbz: '',
|
||||
mine: false,
|
||||
updatedFrom: '',
|
||||
updatedTo: '',
|
||||
createdFrom: '',
|
||||
createdTo: '',
|
||||
minChildren: '',
|
||||
maxChildren: '',
|
||||
minLength: '',
|
||||
maxLength: '',
|
||||
emptyOnly: false,
|
||||
sort: ''
|
||||
};
|
||||
}
|
||||
|
||||
export { emptyCriteria };
|
||||
|
||||
export default {
|
||||
name: 'CmsTreeFilter',
|
||||
props: {
|
||||
// The news menu is a flat list of its own. It carries none of these fields.
|
||||
advanced: { type: Boolean, default: true }
|
||||
},
|
||||
emits: ['filter', 'criteria'],
|
||||
data() {
|
||||
return {
|
||||
text: '',
|
||||
open: false,
|
||||
templates: [],
|
||||
criteria: emptyCriteria()
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// Counts the narrowing fields only. The sort order hides no entry.
|
||||
activeCount() {
|
||||
const c = this.criteria;
|
||||
let count = 0;
|
||||
if (c.template_kurzbz) count++;
|
||||
if (c.mine) count++;
|
||||
if (c.updatedFrom || c.updatedTo) count++;
|
||||
if (c.createdFrom || c.createdTo) count++;
|
||||
if (c.minChildren !== '' || c.maxChildren !== '') count++;
|
||||
if (c.minLength !== '' || c.maxLength !== '') count++;
|
||||
if (c.emptyOnly) count++;
|
||||
return count;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
this.$emit('filter', this.text);
|
||||
},
|
||||
toggle() {
|
||||
this.open = !this.open;
|
||||
if (this.open && !this.templates.length) this.loadTemplates();
|
||||
},
|
||||
loadTemplates() {
|
||||
this.$api
|
||||
.call(ApiCmsAdmin.getTemplates())
|
||||
.then(result => { this.templates = result.data || []; })
|
||||
.catch(this.$fhcAlert.handleSystemError);
|
||||
},
|
||||
reset() {
|
||||
this.text = '';
|
||||
// The watcher emits the cleared criteria.
|
||||
this.criteria = emptyCriteria();
|
||||
this.$emit('filter', '');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// v-model writes before the watcher runs, so the emitted object is complete.
|
||||
criteria: {
|
||||
handler(criteria) { this.$emit('criteria', { ...criteria }); },
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.sortOptions = [
|
||||
{ value: '', phrase: 'cms/sortHierarchie' },
|
||||
{ value: 'titel', phrase: 'cms/titel' },
|
||||
{ value: 'updateamum', phrase: 'cms/sortGeaendert' },
|
||||
{ value: 'insertamum', phrase: 'cms/sortErstellt' },
|
||||
{ value: 'childcount', phrase: 'cms/sortChilds' },
|
||||
{ value: 'contentlength', phrase: 'cms/sortInhaltslaenge' }
|
||||
];
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
:placeholder="$p.t('cms/filterPlatzhalter')"
|
||||
v-model="text"
|
||||
@keydown.enter="submit">
|
||||
<button class="btn btn-outline-secondary" type="button" @click="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button v-if="advanced"
|
||||
type="button"
|
||||
class="btn btn-sm btn-link px-0 text-decoration-none"
|
||||
@click="toggle">
|
||||
<i class="fa-solid fa-fw" :class="open ? 'fa-caret-down' : 'fa-caret-right'"></i>
|
||||
{{ $p.t('cms/filterErweitert') }}
|
||||
<span v-if="activeCount" class="badge bg-primary ms-1">{{ activeCount }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="advanced && open" class="border rounded p-2 mb-2">
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-1">{{ $p.t('cms/vorlage') }}</label>
|
||||
<select class="form-select form-select-sm"
|
||||
v-model="criteria.template_kurzbz">
|
||||
<option value="">{{ $p.t('cms/alleVorlagen') }}</option>
|
||||
<option v-for="t in templates"
|
||||
:key="t.template_kurzbz"
|
||||
:value="t.template_kurzbz">{{ t.bezeichnung }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="cms-filter-mine"
|
||||
v-model="criteria.mine">
|
||||
<label class="form-check-label" for="cms-filter-mine">
|
||||
{{ $p.t('cms/nurEigeneBearbeitungen') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="cms-filter-empty"
|
||||
v-model="criteria.emptyOnly">
|
||||
<label class="form-check-label" for="cms-filter-empty">
|
||||
{{ $p.t('cms/nurLeereInhalte') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-1">{{ $p.t('cms/sortGeaendert') }}</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="date" class="form-control"
|
||||
:title="$p.t('cms/vonDatum')"
|
||||
v-model="criteria.updatedFrom">
|
||||
<input type="date" class="form-control"
|
||||
:title="$p.t('cms/bisDatum')"
|
||||
v-model="criteria.updatedTo">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-1">{{ $p.t('cms/sortErstellt') }}</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="date" class="form-control"
|
||||
:title="$p.t('cms/vonDatum')"
|
||||
v-model="criteria.createdFrom">
|
||||
<input type="date" class="form-control"
|
||||
:title="$p.t('cms/bisDatum')"
|
||||
v-model="criteria.createdTo">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-1">{{ $p.t('cms/sortChilds') }}</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" min="0" class="form-control"
|
||||
:placeholder="$p.t('cms/mindestens')"
|
||||
v-model="criteria.minChildren">
|
||||
<input type="number" min="0" class="form-control"
|
||||
:placeholder="$p.t('cms/hoechstens')"
|
||||
v-model="criteria.maxChildren">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-1">{{ $p.t('cms/sortInhaltslaenge') }}</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" min="0" class="form-control"
|
||||
:placeholder="$p.t('cms/mindestens')"
|
||||
v-model="criteria.minLength">
|
||||
<input type="number" min="0" class="form-control"
|
||||
:placeholder="$p.t('cms/hoechstens')"
|
||||
v-model="criteria.maxLength">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label mb-1">{{ $p.t('cms/sortierung') }}</label>
|
||||
<select class="form-select form-select-sm"
|
||||
v-model="criteria.sort">
|
||||
<option v-for="o in sortOptions" :key="o.value" :value="o.value">
|
||||
{{ $p.t(o.phrase) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @click="reset">
|
||||
{{ $p.t('cms/filterZuruecksetzen') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import ApiCms from '../../api/factory/cms.js';
|
||||
|
||||
/**
|
||||
* Content component: lists the child pages of a content as a menu.
|
||||
*
|
||||
* It replaces a hand written box of content.php links. Those links carry a fixed
|
||||
* content_id and break when a page moves. This list follows tbl_contentchild, which the
|
||||
* Kindelemente tab of the CMS admin already maintains.
|
||||
*
|
||||
* Without the content-id property the menu shows the children of the page it sits on.
|
||||
* With it, the menu shows the children of another page. The second form exists because
|
||||
* the same menu appears on several pages today.
|
||||
*/
|
||||
export default {
|
||||
name: 'ContentchildMenu',
|
||||
inject: {
|
||||
contentcomponentContentId: { default: null }
|
||||
},
|
||||
props: {
|
||||
contentId: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
kinder: [],
|
||||
loading: true,
|
||||
failed: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sprache() {
|
||||
return this.$p.user_language.value;
|
||||
},
|
||||
// The property wins. Without it the menu takes the page it sits on.
|
||||
// Vue unwraps an injected ref on the instance, so no .value here.
|
||||
quelle() {
|
||||
return this.contentId || this.contentcomponentContentId;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
if (!this.quelle)
|
||||
{
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.failed = false;
|
||||
this.$api
|
||||
.call(ApiCms.getContentChilds(this.quelle, this.sprache))
|
||||
.then(res => { this.kinder = res.data || []; })
|
||||
.catch(() => { this.failed = true; })
|
||||
.finally(() => { this.loading = false; });
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
quelle() { this.load(); },
|
||||
// The titles come per language, so a language switch needs a new request.
|
||||
sprache() { this.load(); }
|
||||
},
|
||||
created() {
|
||||
this.load();
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="fhc-contentcomponent-contentchildmenu">
|
||||
<div v-if="loading" class="text-muted">...</div>
|
||||
<div v-else-if="failed" class="alert alert-warning py-2">
|
||||
{{ $p.t('cms/ccLadefehlerMenue') }}
|
||||
</div>
|
||||
<!-- An empty menu renders nothing. A reader has no use for the note that a
|
||||
page has no children, and the group rule may have removed every entry. -->
|
||||
<ul v-else-if="kinder.length" class="list-unstyled mb-0">
|
||||
<li v-for="kind in kinder" :key="kind.content_id" class="mb-1">
|
||||
<router-link :to="{ name: 'Content', params: { content_id: kind.content_id } }">
|
||||
{{ kind.titel }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import { components } from './components.js';
|
||||
import { scanContentcomponents } from './scan.js';
|
||||
|
||||
/**
|
||||
* Renders CMS content HTML and mounts the content components it declares.
|
||||
*
|
||||
* Use it in place of a plain `<div v-html="content">`. It owns the root element, the
|
||||
* change watch and the Teleports, so a host site needs no ref and no lifecycle code.
|
||||
*
|
||||
* Teleport, not a separate createApp: the components stay in this app's component tree.
|
||||
* They therefore share the one $api instance, the one $p instance, the router and the
|
||||
* provided keys. A createApp per marker would build a second of each.
|
||||
*/
|
||||
export default {
|
||||
name: 'ContentcomponentHost',
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// The page this content belongs to. contentchild-menu needs it when the editor
|
||||
// gave no explicit id.
|
||||
contentId: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
provide() {
|
||||
// Vue.computed keeps the value live, the same way Cis.js provides its keys.
|
||||
return {
|
||||
contentcomponentContentId: Vue.computed(() => this.contentId)
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
markers: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
rescan() {
|
||||
this.markers = scanContentcomponents(this.$refs.root);
|
||||
},
|
||||
resolve(name) {
|
||||
return components[name];
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// Vue replaces the whole v-html subtree when the string changes. A marker that
|
||||
// still points at a removed node makes Teleport write into a detached element,
|
||||
// and the component vanishes without an error. Drop the markers first, let the
|
||||
// new HTML render, then scan again.
|
||||
content() {
|
||||
this.markers = [];
|
||||
this.$nextTick(this.rescan);
|
||||
}
|
||||
},
|
||||
// A failing content component must not take down the content around it.
|
||||
errorCaptured(err, instance, info) {
|
||||
console.error('Contentcomponent failed:', info, err);
|
||||
return false;
|
||||
},
|
||||
mounted() {
|
||||
// The components below carry their texts in category cms. $p.t would fetch it by
|
||||
// itself on the first miss and fill in when it arrives, which shows an empty line
|
||||
// for that moment. One request here from the single gateway spares that.
|
||||
this.$p.loadCategory('cms');
|
||||
this.rescan();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.markers = [];
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div ref="root" v-html="content"></div>
|
||||
<Teleport v-for="marker in markers" :key="marker.key" :to="marker.el">
|
||||
<component :is="resolve(marker.name)" v-bind="marker.props"></component>
|
||||
</Teleport>
|
||||
`
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import ApiCms from '../../api/factory/cms.js';
|
||||
import DokumentListe from './DokumentListe.js';
|
||||
|
||||
/**
|
||||
* Content component: named DMS documents, in the order the editor gave.
|
||||
*/
|
||||
export default {
|
||||
name: 'DmsDokumente',
|
||||
components: {
|
||||
DokumentListe
|
||||
},
|
||||
props: {
|
||||
dmsIds: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dokumente: [],
|
||||
loading: true,
|
||||
failed: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true;
|
||||
this.failed = false;
|
||||
this.$api
|
||||
.call(ApiCms.getDmsDokumente(this.dmsIds))
|
||||
.then(res => { this.dokumente = res.data || []; })
|
||||
.catch(() => { this.failed = true; })
|
||||
.finally(() => { this.loading = false; });
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
dmsIds() { this.load(); }
|
||||
},
|
||||
created() {
|
||||
this.load();
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="fhc-contentcomponent-dmsdokumente">
|
||||
<div v-if="loading" class="text-muted">...</div>
|
||||
<div v-else-if="failed" class="alert alert-warning py-2">
|
||||
{{ $p.t('cms/ccLadefehlerDokumente') }}
|
||||
</div>
|
||||
<!-- An empty list renders nothing. Every named document may have dropped out
|
||||
through the category rule, and that is not the reader's business. -->
|
||||
<dokument-liste
|
||||
v-else-if="dokumente.length"
|
||||
:dokumente="dokumente"
|
||||
></dokument-liste>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import ApiCms from '../../api/factory/cms.js';
|
||||
import DokumentListe from './DokumentListe.js';
|
||||
|
||||
/**
|
||||
* Content component: the published documents of one DMS category.
|
||||
*
|
||||
* The low maintenance variant. It lists every document of the category whose newest
|
||||
* version carries cis_suche, so a replaced or added document appears without an edit in
|
||||
* the CMS. Use dms-dokumente when the editor must pick single files.
|
||||
*/
|
||||
export default {
|
||||
name: 'DmsListe',
|
||||
components: {
|
||||
DokumentListe
|
||||
},
|
||||
props: {
|
||||
kategorieKurzbz: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
// 0 lists the category itself, which is what a marker without the attribute
|
||||
// means. Higher takes that many levels of sub categories with it.
|
||||
tiefe: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dokumente: [],
|
||||
zugriff: true,
|
||||
loading: true,
|
||||
failed: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true;
|
||||
this.failed = false;
|
||||
this.$api
|
||||
.call(ApiCms.getDmsKategorie(this.kategorieKurzbz, this.tiefe))
|
||||
.then(res => {
|
||||
this.zugriff = res.data.zugriff;
|
||||
this.dokumente = res.data.dokumente || [];
|
||||
})
|
||||
.catch(() => { this.failed = true; })
|
||||
.finally(() => { this.loading = false; });
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
kategorieKurzbz() { this.load(); },
|
||||
tiefe() { this.load(); }
|
||||
},
|
||||
created() {
|
||||
this.load();
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="fhc-contentcomponent-dmsliste">
|
||||
<div v-if="loading" class="text-muted">...</div>
|
||||
<div v-else-if="failed" class="alert alert-warning py-2">
|
||||
{{ $p.t('cms/ccLadefehlerDokumente') }}
|
||||
</div>
|
||||
<!-- A reader without the entitlement sees nothing at all, not a hint that
|
||||
something is hidden here. -->
|
||||
<template v-else-if="zugriff">
|
||||
<div v-if="!dokumente.length" class="text-muted">
|
||||
{{ $p.t('cms/ccKeineDokumente') }}
|
||||
</div>
|
||||
<dokument-liste v-else :dokumente="dokumente"></dokument-liste>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
// Mimetype to Font Awesome 6 icon. The first match wins.
|
||||
const ICONS = [
|
||||
[/pdf/, 'fa-file-pdf'],
|
||||
[/word|opendocument\.text/, 'fa-file-word'],
|
||||
[/excel|sheet|csv/, 'fa-file-excel'],
|
||||
[/powerpoint|presentation/, 'fa-file-powerpoint'],
|
||||
[/^image\//, 'fa-file-image'],
|
||||
[/zip|compressed|tar|rar|7z/, 'fa-file-zipper'],
|
||||
[/^video\//, 'fa-file-video'],
|
||||
[/^audio\//, 'fa-file-audio'],
|
||||
[/^text\//, 'fa-file-lines']
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders a list of DMS documents as download links.
|
||||
*
|
||||
* It fetches nothing. dms-liste and dms-dokumente both fill it, so a document reads the
|
||||
* same whether the editor named a category or single ids.
|
||||
*/
|
||||
export default {
|
||||
name: 'DokumentListe',
|
||||
props: {
|
||||
dokumente: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
icon(mimetype) {
|
||||
const typ = (mimetype || '').toLowerCase();
|
||||
const treffer = ICONS.find(([muster]) => muster.test(typ));
|
||||
return treffer ? treffer[1] : 'fa-file';
|
||||
},
|
||||
href(dokument) {
|
||||
return FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/dms.php?id=' + dokument.dms_id;
|
||||
},
|
||||
datum(wert) {
|
||||
if (!wert) return '';
|
||||
const teile = String(wert).slice(0, 10).split('-');
|
||||
return teile.length === 3 ? teile[2] + '.' + teile[1] + '.' + teile[0] : '';
|
||||
}
|
||||
},
|
||||
template: /*html*/ `
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li v-for="dokument in dokumente" :key="dokument.dms_id" class="mb-2">
|
||||
<a :href="href(dokument)" target="_blank" rel="noopener">
|
||||
<i class="fa-regular" :class="icon(dokument.mimetype)"></i>
|
||||
{{ dokument.name }}
|
||||
</a>
|
||||
<span v-if="dokument.geaendert" class="text-muted small ms-2">
|
||||
{{ datum(dokument.geaendert) }}
|
||||
</span>
|
||||
<div v-if="dokument.beschreibung" class="text-muted small">
|
||||
{{ dokument.beschreibung }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
`
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import ApiCms from '../../api/factory/cms.js';
|
||||
import PersonBlock from './PersonBlock.js';
|
||||
|
||||
/**
|
||||
* Content component: every person assigned to an organisation unit.
|
||||
*
|
||||
* The low maintenance variant. A new colleague appears without an edit in the CMS, and a
|
||||
* leaving one disappears. The price is that the editor cannot steer who is shown. Use
|
||||
* person-block when that control matters.
|
||||
*
|
||||
* The function label per person comes from tbl_benutzerfunktion.bezeichnung,
|
||||
* and falls back to tbl_funktion.beschreibung.
|
||||
*/
|
||||
export default {
|
||||
name: 'OePersonen',
|
||||
components: {
|
||||
PersonBlock
|
||||
},
|
||||
props: {
|
||||
oeKurzbz: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
foto: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 0 lists the unit itself, which is what a marker without the attribute means.
|
||||
// Higher takes that many levels of sub units with it.
|
||||
tiefe: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
personen: [],
|
||||
loading: true,
|
||||
failed: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true;
|
||||
this.failed = false;
|
||||
this.$api
|
||||
.call(ApiCms.getOePersonen(this.oeKurzbz, this.foto, this.tiefe))
|
||||
.then(res => { this.personen = res.data || []; })
|
||||
.catch(() => { this.failed = true; })
|
||||
.finally(() => { this.loading = false; });
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
oeKurzbz() { this.load(); },
|
||||
foto() { this.load(); },
|
||||
tiefe() { this.load(); }
|
||||
},
|
||||
created() {
|
||||
this.load();
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="fhc-contentcomponent-oepersonen">
|
||||
<div v-if="loading" class="text-muted">...</div>
|
||||
<div v-else-if="failed" class="alert alert-warning py-2">
|
||||
{{ $p.t('cms/ccLadefehlerPersonen') }}
|
||||
</div>
|
||||
<div v-else-if="!personen.length" class="text-muted">
|
||||
{{ $p.t('cms/ccKeinePersonen') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<person-block
|
||||
v-for="person in personen"
|
||||
:key="person.uid"
|
||||
:uid="person.uid"
|
||||
:name="person.name"
|
||||
:funktion="person.funktion"
|
||||
:telefon="person.telefon"
|
||||
:email="person.email"
|
||||
:ort="person.ort"
|
||||
:foto="person.foto"
|
||||
></person-block>
|
||||
</template>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Renders one person the way the teambox blocks do today: name, function, phone, room,
|
||||
* mail, and an optional photo.
|
||||
*
|
||||
* It fetches nothing. person-block and oe-personen both fill it, so one person reads the
|
||||
* same whether the editor named a UID or an organisation unit.
|
||||
*/
|
||||
export default {
|
||||
name: 'PersonBlock',
|
||||
props: {
|
||||
uid: String,
|
||||
name: String,
|
||||
funktion: String,
|
||||
telefon: String,
|
||||
email: String,
|
||||
ort: String,
|
||||
foto: String
|
||||
},
|
||||
computed: {
|
||||
// tel: takes digits and a leading plus.
|
||||
telefonLink() {
|
||||
return this.telefon ? 'tel:' + this.telefon.replace(/[^\d+]/g, '') : null;
|
||||
},
|
||||
fotoQuelle() {
|
||||
return this.foto ? 'data:image/png;base64,' + this.foto : null;
|
||||
}
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="fhc-contentcomponent-person mb-3">
|
||||
<img
|
||||
v-if="fotoQuelle"
|
||||
:src="fotoQuelle"
|
||||
alt=""
|
||||
class="mb-2"
|
||||
style="width: 110px; height: auto; object-fit: scale-down;"
|
||||
>
|
||||
<div>
|
||||
<router-link v-if="uid" :to="{ name: 'ProfilView', params: { uid: uid } }">
|
||||
{{ name }}
|
||||
</router-link>
|
||||
<span v-else>{{ name }}</span>
|
||||
</div>
|
||||
<div v-if="funktion">{{ funktion }}</div>
|
||||
<div v-if="telefon">
|
||||
T: <a :href="telefonLink">{{ telefon }}</a>
|
||||
</div>
|
||||
<div v-if="ort">R: {{ ort }}</div>
|
||||
<div v-if="email">
|
||||
E: <a :href="'mailto:' + email">{{ email }}</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import ApiCms from '../../api/factory/cms.js';
|
||||
import PersonBlock from './PersonBlock.js';
|
||||
|
||||
/**
|
||||
* Content component: one person, named by UID.
|
||||
*
|
||||
* The flexible variant. The editor decides who appears and in which order by placing
|
||||
* several markers. Use oe-personen instead when the list should follow the organizational structure.
|
||||
*
|
||||
* The function label comes from the marker, not from the database, because the database
|
||||
* name does not always fit. It needs no phrase key: the CMS holds one content per
|
||||
* language, so the German page carries the German label and the English page the English
|
||||
* one.
|
||||
*/
|
||||
export default {
|
||||
name: 'PersonKontakt',
|
||||
components: {
|
||||
PersonBlock
|
||||
},
|
||||
props: {
|
||||
uid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
funktion: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
foto: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
person: null,
|
||||
loading: true,
|
||||
failed: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true;
|
||||
this.failed = false;
|
||||
this.$api
|
||||
.call(ApiCms.getPerson(this.uid, this.foto))
|
||||
.then(res => { this.person = res.data; })
|
||||
.catch(() => { this.failed = true; })
|
||||
.finally(() => { this.loading = false; });
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
uid() { this.load(); },
|
||||
foto() { this.load(); }
|
||||
},
|
||||
created() {
|
||||
this.load();
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="fhc-contentcomponent-personkontakt">
|
||||
<div v-if="loading" class="text-muted">...</div>
|
||||
<div v-else-if="failed" class="alert alert-warning py-2">
|
||||
{{ $p.t('cms/ccLadefehlerPerson') }}
|
||||
</div>
|
||||
<person-block
|
||||
v-else-if="person"
|
||||
:uid="person.uid"
|
||||
:name="person.name"
|
||||
:funktion="funktion"
|
||||
:telefon="person.telefon"
|
||||
:email="person.email"
|
||||
:ort="person.ort"
|
||||
:foto="person.foto"
|
||||
></person-block>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
# Contentcomponents
|
||||
|
||||
Vue components that CMS editors can place inside CMS content. The editor inserts a marker
|
||||
div, CIS4 finds it and teleports a real component into it. The component fetches its own
|
||||
data and has everything a normal component has: `$api`, `$p`, the router, the provided
|
||||
keys.
|
||||
|
||||
## The marker
|
||||
|
||||
```html
|
||||
<div class="fhc-contentcomponent" data-fhc-component="dms-liste" data-kategorie-kurzbz="rl"></div>
|
||||
```
|
||||
|
||||
Editors do not write this. The **Contentcomponent** button in TinyMCE 5 opens a dialog
|
||||
built from `catalog.js`.
|
||||
|
||||
## Add a component
|
||||
|
||||
| File | What to add |
|
||||
|---|---|
|
||||
| `MeinBaustein.js` | the component |
|
||||
| `catalog.js` | an entry in `catalog`: name, label, props |
|
||||
| `components.js` | the import plus `'mein-baustein': MeinBaustein` |
|
||||
|
||||
Catalog and map are two hand-written lists. `components.js` logs a mismatch on load.
|
||||
|
||||
### The component
|
||||
|
||||
Copy `DmsListe.js`, the shortest complete example. Requirements:
|
||||
|
||||
- It stands alone. No parent props, no route params.
|
||||
- It fetches in `created()`, and again from a `watch` on every prop.
|
||||
- It handles loading, failure and empty.
|
||||
- Root class `fhc-contentcomponent-<name>`.
|
||||
- `template` string. There is no `.vue` build.
|
||||
|
||||
To get the id of the page the marker sits on:
|
||||
|
||||
```js
|
||||
inject: { contentcomponentContentId: { default: null } }
|
||||
```
|
||||
|
||||
`ContentchildMenu.js` shows the full pattern.
|
||||
|
||||
Presentational helpers belong in this folder but not in the catalog. See `PersonBlock.js`
|
||||
and `DokumentListe.js`.
|
||||
|
||||
### The catalog entry
|
||||
|
||||
```js
|
||||
{
|
||||
name: 'mein-baustein', // data-fhc-component, and the key in components.js
|
||||
labelPhrase: 'ccMeinBaustein', // phrase in category cms, shown in the picker
|
||||
props: {
|
||||
'mein-wert': { type: 'string', required: true, labelPhrase: 'ccMeinWert' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The catalog is an allow-list. `scan.js` reads only the attributes declared here and drops
|
||||
every other one.
|
||||
|
||||
| Prop key | Meaning |
|
||||
|---|---|
|
||||
| `type` | `string`, `number` or `boolean`. Anything else stays a string. |
|
||||
| `required` | A missing attribute makes `scan.js` skip the whole marker. The dialog does not enforce it. |
|
||||
| `label` | Field label in the dialog. It defaults to the attribute name. |
|
||||
| `source` | Fills the field from a server list: `oe`, `person`, `dmskategorie`. |
|
||||
| `picker` | Only `'dms'`. Adds category and document selects that append ids to the field. |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **kebab-case attribute names.** The HTML parser lowercases them, so `data-meinWert` does
|
||||
not survive. Vue maps `mein-wert` to the prop `meinWert`.
|
||||
- **`boolean` means `"true"` or `"1"`.** Everything else is false, and the dialog never
|
||||
writes a false into the marker.
|
||||
- **Catalog name is not the file name.** `person-block` resolves to `PersonKontakt.js`.
|
||||
- **The marker is a `div`, never a custom tag.** TinyMCE 3 in the legacy admin treats an
|
||||
unknown tag as inline and can split it on save.
|
||||
- **20 components per content** — `CONTENTCOMPONENT_MAX` in `catalog.js`.
|
||||
- **Content is never compiled as a Vue template.** An editor cannot write expressions.
|
||||
- **News renders no components on purpose.** A list shows many contents at once, and every
|
||||
component would start its own request.
|
||||
- A throwing component does not take the content down. The host catches it and logs it.
|
||||
|
||||
## Server data
|
||||
|
||||
Model, controller, factory. Follow `getDmsKategorie`.
|
||||
|
||||
1. `application/models/content/Dms_model.php` — the query.
|
||||
2. `application/controllers/api/frontend/v1/Cms.php` — the method, plus
|
||||
`'getMeineDaten' => self::PERM_LOGGED` in the constructor. Validate with
|
||||
`form_validation`.
|
||||
3. `public/js/api/factory/cms.js` — the request function.
|
||||
|
||||
A missing entitlement answers with success and an empty list, not with an error. The
|
||||
reader gets no hint that something is hidden.
|
||||
|
||||
## A new dialog source
|
||||
|
||||
1. `CmsAdmin.php` — the endpoint, plus `['basis/cms:r']` in the constructor.
|
||||
2. `cmsadmin.js` — the request function.
|
||||
3. `ContentcomponentDialog.js` — an entry in `SOURCES`:
|
||||
|
||||
```js
|
||||
meineliste: {
|
||||
request: () => ApiCmsAdmin.getMeineListe(),
|
||||
item: e => ({ value: e.kurzbz, text: e.bezeichnung })
|
||||
}
|
||||
```
|
||||
|
||||
4. `source: 'meineliste'` in the catalog prop.
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Catalog of the components that may be placed inside CMS content.
|
||||
*
|
||||
* A component is available to CMS editors only if it has an entry here. An arbitrary
|
||||
* project component cannot work in content: it expects props from a parent, inject keys
|
||||
* and route params. Content supplies attribute strings only. A component listed here is
|
||||
* written for content use: lightweight, one fetch, props chosen in the CMS.
|
||||
*
|
||||
* Marker convention in the content HTML:
|
||||
*
|
||||
* <div class="fhc-contentcomponent" data-fhc-component="NAME" data-PROP="VALUE"></div>
|
||||
*
|
||||
* A div is used instead of a custom tag on purpose. TinyMCE 5 (CIS4 admin) and TinyMCE 3
|
||||
* (legacy admin) both treat a div as a block. An unknown tag counts as inline and the
|
||||
* legacy admin can split or nest it on save, which would corrupt shared content.
|
||||
*/
|
||||
|
||||
// Class on every marker. TinyMCE uses it for noneditable and for the editor styling.
|
||||
export const CONTENTCOMPONENT_CLASS = 'fhc-contentcomponent';
|
||||
|
||||
// Attribute holding the component name.
|
||||
export const CONTENTCOMPONENT_ATTR = 'data-fhc-component';
|
||||
|
||||
// Upper bound per content. A paste accident must not start dozens of requests.
|
||||
export const CONTENTCOMPONENT_MAX = 20;
|
||||
|
||||
/**
|
||||
* One entry per available component.
|
||||
*
|
||||
* name value of data-fhc-component, and the key in components.js
|
||||
* labelPhrase phrase in category cms, shown in the insert dialog
|
||||
* props attribute name (kebab-case) => { type, required, labelPhrase, source }
|
||||
*
|
||||
* Attribute names must be kebab-case. The HTML parser lowercases every attribute name,
|
||||
* so camelCase would not survive. Vue maps the kebab key back to the camelCase prop.
|
||||
*
|
||||
* TODO evaluate where "source" belongs. It tells the insert dialog how to fill the field.
|
||||
* Only 'text' and 'oe' exist today. A growing list may deserve its own place.
|
||||
*/
|
||||
export const catalog = [
|
||||
{
|
||||
name: 'oe-personen',
|
||||
labelPhrase: 'ccOePersonen',
|
||||
props: {
|
||||
'oe-kurzbz': {
|
||||
type: 'string',
|
||||
required: true,
|
||||
labelPhrase: 'ccOrganisationseinheit',
|
||||
source: 'oe'
|
||||
},
|
||||
'foto': {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
labelPhrase: 'ccFotosAnzeigen'
|
||||
},
|
||||
'tiefe': {
|
||||
type: 'number',
|
||||
required: false,
|
||||
labelPhrase: 'ccTiefeOe'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'person-block',
|
||||
labelPhrase: 'ccPersonBlock',
|
||||
props: {
|
||||
'uid': {
|
||||
type: 'string',
|
||||
required: true,
|
||||
labelPhrase: 'ccPerson',
|
||||
source: 'person'
|
||||
},
|
||||
'funktion': {
|
||||
type: 'string',
|
||||
required: false,
|
||||
labelPhrase: 'ccFunktionsbezeichnung'
|
||||
},
|
||||
'foto': {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
labelPhrase: 'ccFotoAnzeigen'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'dms-dokumente',
|
||||
labelPhrase: 'ccDmsDokumente',
|
||||
props: {
|
||||
'dms-ids': {
|
||||
type: 'string',
|
||||
required: true,
|
||||
labelPhrase: 'ccDmsIds',
|
||||
// Adds a category filter and a document list to the dialog. Choosing a
|
||||
// document appends its id to this field.
|
||||
picker: 'dms'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'dms-liste',
|
||||
labelPhrase: 'ccDmsListe',
|
||||
props: {
|
||||
'kategorie-kurzbz': {
|
||||
type: 'string',
|
||||
required: true,
|
||||
labelPhrase: 'ccDmsKategorie',
|
||||
source: 'dmskategorie'
|
||||
},
|
||||
'tiefe': {
|
||||
type: 'number',
|
||||
required: false,
|
||||
labelPhrase: 'ccTiefeDms'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'contentchild-menu',
|
||||
labelPhrase: 'ccContentchildMenu',
|
||||
props: {
|
||||
'content-id': {
|
||||
type: 'number',
|
||||
required: false,
|
||||
labelPhrase: 'ccContentId'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export function getDescriptor(name)
|
||||
{
|
||||
return catalog.find(entry => entry.name === name) || null;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { catalog } from './catalog.js';
|
||||
import OePersonen from './OePersonen.js';
|
||||
import DmsListe from './DmsListe.js';
|
||||
import DmsDokumente from './DmsDokumente.js';
|
||||
import PersonKontakt from './PersonKontakt.js';
|
||||
import ContentchildMenu from './ContentchildMenu.js';
|
||||
|
||||
/**
|
||||
* Component name => component.
|
||||
*
|
||||
* Static imports. At the current scale this is simpler than a dynamic import and it
|
||||
* survives a rollup bundle. Switch to `(await import(file)).default`, the way
|
||||
* Dashboard/Item.js loads a widget, once the catalog gets long.
|
||||
*/
|
||||
export const components = {
|
||||
'oe-personen': OePersonen,
|
||||
'person-block': PersonKontakt,
|
||||
'dms-liste': DmsListe,
|
||||
'dms-dokumente': DmsDokumente,
|
||||
'contentchild-menu': ContentchildMenu
|
||||
};
|
||||
|
||||
// The catalog and this map are written by hand and must name the same set.
|
||||
for (const entry of catalog)
|
||||
{
|
||||
if (!components[entry.name])
|
||||
console.error('Contentcomponent "' + entry.name + '" is in the catalog but has no component.');
|
||||
}
|
||||
for (const name of Object.keys(components))
|
||||
{
|
||||
if (!catalog.some(entry => entry.name === name))
|
||||
console.error('Contentcomponent "' + name + '" has a component but no catalog entry.');
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import {
|
||||
catalog,
|
||||
getDescriptor,
|
||||
CONTENTCOMPONENT_ATTR,
|
||||
CONTENTCOMPONENT_MAX
|
||||
} from './catalog.js';
|
||||
|
||||
// Runs over the lifetime of the page. Two identical markers in one content therefore get
|
||||
// two distinct keys, which Vue needs to keep their Teleports apart.
|
||||
let keyCounter = 0;
|
||||
|
||||
// One log line per problem, not one per render.
|
||||
const reported = new Set();
|
||||
|
||||
function report(message)
|
||||
{
|
||||
if (reported.has(message))
|
||||
return;
|
||||
reported.add(message);
|
||||
console.error('Contentcomponent: ' + message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one attribute string into the declared type. Returns undefined on a bad value.
|
||||
*/
|
||||
function coerce(raw, spec)
|
||||
{
|
||||
switch (spec.type)
|
||||
{
|
||||
case 'number':
|
||||
{
|
||||
const value = Number(raw);
|
||||
return (raw === '' || Number.isNaN(value)) ? undefined : value;
|
||||
}
|
||||
case 'boolean':
|
||||
return raw === 'true' || raw === '1';
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the props of one marker. Only an attribute named in the schema is read. Every
|
||||
* other attribute is dropped. This allow-list is the boundary between the content and
|
||||
* the component.
|
||||
*/
|
||||
function readProps(el, descriptor)
|
||||
{
|
||||
const props = {};
|
||||
|
||||
for (const [name, spec] of Object.entries(descriptor.props || {}))
|
||||
{
|
||||
const raw = el.getAttribute('data-' + name);
|
||||
|
||||
if (raw === null)
|
||||
{
|
||||
if (spec.required)
|
||||
{
|
||||
report('"' + descriptor.name + '" misses the required property "' + name + '".');
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = coerce(raw, spec);
|
||||
if (value === undefined)
|
||||
{
|
||||
report('"' + descriptor.name + '" got the value "' + raw + '" for "' + name
|
||||
+ '", which is not a ' + spec.type + '.');
|
||||
return null;
|
||||
}
|
||||
|
||||
props[name] = value;
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every marker below root and returns the ones that can be rendered.
|
||||
* An unknown name or a bad property is logged and skipped, never shown to a reader.
|
||||
*
|
||||
* @param {Element} root
|
||||
* @return {Array<{el: Element, key: string, name: string, props: Object}>}
|
||||
*/
|
||||
export function scanContentcomponents(root)
|
||||
{
|
||||
if (!root)
|
||||
return [];
|
||||
|
||||
const elements = Array.from(root.querySelectorAll('[' + CONTENTCOMPONENT_ATTR + ']'));
|
||||
|
||||
if (elements.length > CONTENTCOMPONENT_MAX)
|
||||
{
|
||||
report('a content holds ' + elements.length + ' components. Only the first '
|
||||
+ CONTENTCOMPONENT_MAX + ' are rendered.');
|
||||
}
|
||||
|
||||
const markers = [];
|
||||
|
||||
for (const el of elements.slice(0, CONTENTCOMPONENT_MAX))
|
||||
{
|
||||
const name = el.getAttribute(CONTENTCOMPONENT_ATTR);
|
||||
const descriptor = getDescriptor(name);
|
||||
|
||||
if (!descriptor)
|
||||
{
|
||||
report('"' + name + '" is not in the catalog. Known: '
|
||||
+ catalog.map(entry => entry.name).join(', ') + '.');
|
||||
continue;
|
||||
}
|
||||
|
||||
const props = readProps(el, descriptor);
|
||||
if (props === null)
|
||||
continue;
|
||||
|
||||
// A fresh marker gets a key and loses its inner HTML. Teleport adds its children
|
||||
// and keeps the existing ones, so a fallback text would stay under the component.
|
||||
// A marker that already carries a key holds a mounted component. Do not clear it.
|
||||
if (!el.dataset.fhcComponentKey)
|
||||
{
|
||||
el.dataset.fhcComponentKey = String(++keyCounter);
|
||||
el.innerHTML = '';
|
||||
}
|
||||
|
||||
markers.push({
|
||||
el: el,
|
||||
key: el.dataset.fhcComponentKey,
|
||||
name: name,
|
||||
props: props
|
||||
});
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import BsModal from "../Bootstrap/Modal.js";
|
||||
import HeightTransition from "../Tranistion/HeightTransition.js";
|
||||
import MissingPermissionWidget from "../DashboardWidget/MissingPermission.js";
|
||||
|
||||
import { enableDragDropTouch } from "../../../../vendor/drag-drop-touch-js/dragdroptouch/dist/drag-drop-touch.esm.min.js";
|
||||
|
||||
@@ -112,6 +113,12 @@ export default {
|
||||
isPinned() {
|
||||
return this.place?.pinned ? true : false;
|
||||
},
|
||||
permitted() {
|
||||
// widgets without a linked permission (or before the template loaded)
|
||||
// are considered permitted; the backend sets permitted === false only
|
||||
// when the user is missing the widget's required permission
|
||||
return this.widgetTemplate?.permitted !== false;
|
||||
},
|
||||
ready() {
|
||||
return this.component && this.arguments !== null;
|
||||
}
|
||||
@@ -169,7 +176,11 @@ export default {
|
||||
&& this.widgetTemplate.widget_id
|
||||
&& this.widgetTemplate.arguments
|
||||
) {
|
||||
let component = (await import(this.widgetTemplate.setup.file)).default;
|
||||
// render the "missing permission" screen instead of the actual
|
||||
// widget when the user does not hold the widget's linked permission
|
||||
let component = this.permitted
|
||||
? (await import(this.widgetTemplate.setup.file)).default
|
||||
: MissingPermissionWidget;
|
||||
this.$options.components["widget" + this.widgetTemplate.widget_id] = component;
|
||||
this.component = "widget" + this.widgetTemplate.widget_id;
|
||||
this.arguments = { ...this.widgetTemplate.arguments, ...this.config };
|
||||
@@ -269,7 +280,7 @@ export default {
|
||||
</template>
|
||||
<!-- widget link -->
|
||||
<a
|
||||
v-if="widgetTemplate.setup.cis4link"
|
||||
v-if="widgetTemplate.setup.cis4link && permitted"
|
||||
:href="getWidgetC4Link(widgetTemplate)"
|
||||
class="col-auto ms-auto"
|
||||
:aria-label="$p.t('dashboard/widget_link')"
|
||||
|
||||
@@ -14,10 +14,14 @@ export default {
|
||||
callbacks: {}
|
||||
}),
|
||||
computed: {
|
||||
// filter widgets away the user has no permissions for
|
||||
availableWidgets() {
|
||||
return (this.widgets || []).filter(widget => widget.permitted !== false);
|
||||
},
|
||||
hasAnyWidgets() {
|
||||
if (!this.widgets)
|
||||
return false;
|
||||
if (!this.widgets.length && !this.hiddenWidgets?.length)
|
||||
if (!this.availableWidgets.length && !this.hiddenWidgets?.length)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -54,7 +58,7 @@ export default {
|
||||
<template v-slot:default>
|
||||
<template v-if="widgets && hiddenWidgets">
|
||||
<div
|
||||
v-if="!widgets.length && !hiddenWidgets.length"
|
||||
v-if="!availableWidgets.length && !hiddenWidgets.length"
|
||||
class="row g-2"
|
||||
>
|
||||
<div>{{ $p.t('dashboard/noWidgetsAvailable') }}</div>
|
||||
@@ -74,16 +78,17 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="widgets.length"
|
||||
v-if="availableWidgets.length"
|
||||
class="row g-2"
|
||||
>
|
||||
<div
|
||||
v-for="widget in widgets"
|
||||
<template
|
||||
v-for="widget in availableWidgets"
|
||||
:key="widget.widget_id"
|
||||
class="widget-icon-container col-sm-6 col-md-4 col-lg-3 col-xl-2"
|
||||
>
|
||||
<widget-icon @select="pick" :widget="widget"></widget-icon>
|
||||
</div>
|
||||
<div v-if="widget.permitted" class="widget-icon-container col-sm-6 col-md-4 col-lg-3 col-xl-2">
|
||||
<widget-icon @select="pick" :widget="widget"></widget-icon>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import AbstractWidget from './Abstract.js';
|
||||
|
||||
/**
|
||||
* Fallback screen rendered in place of a widget when the current user is missing
|
||||
* the permission (berechtigung_kurzbz) linked to that widget. Used for widgets
|
||||
* that are already on a user's dashboard but whose required permission the user
|
||||
* no longer holds.
|
||||
*/
|
||||
export default {
|
||||
mixins: [
|
||||
AbstractWidget
|
||||
],
|
||||
created() {
|
||||
this.$emit('setConfig', false);
|
||||
},
|
||||
template: /*html*/`
|
||||
<div class="dashboard-widget-missing-permission d-flex flex-column justify-content-center align-items-center text-center h-100 p-3 text-body-secondary">
|
||||
<i class="fa-solid fa-lock fa-2x mb-2" aria-hidden="true"></i>
|
||||
<p class="mb-0">{{ $p.t('dashboard/widget_missing_permission') }}</p>
|
||||
</div>`
|
||||
}
|
||||
@@ -256,7 +256,6 @@ export default {
|
||||
<div id="FhcCarouselContainer" style="height: 100%;" ref="carousel" class="carousel slide fhc-carousel ms-2" data-bs-interval="false">
|
||||
|
||||
<div class="carousel-inner" ref="carouselInner" style="height: 100%; max-width: 100%;">
|
||||
<!-- No contentcomponent-host on purpose: the carousel shows many teasers, and every component would start its own fetch. -->
|
||||
<div ref="carouselItems" v-for="(news, index) in newsList" class="carousel-item fhc-news-card-item" style="overflow-y: auto; overflow-x: hidden; height: 100%;" :id="'card-'+news.news_id" v-html="news.content_obj.content"/>
|
||||
</div>
|
||||
<button @click="setPrev" style="z-index: 100; overflow: hidden; margin-left: 4px; width:35px;" data-bs-target="#FhcCarouselContainer" class="carousel-control-prev" type="button">
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import InViewTooltip from '../directives/inViewTooltip.js';
|
||||
|
||||
/**
|
||||
* ? icon with a help text in the in-view tooltip. The panel stays inside the viewport, also at the
|
||||
* screen edge, in a modal and on a phone: a plain tooltip ran out of the screen there.
|
||||
*
|
||||
* Help texts are long, so the panel is sticky by default: it sticks after a hover of 1.4 s or a click,
|
||||
* a tap toggles it on touch. sticky: false makes it a plain hover tooltip.
|
||||
*
|
||||
* The view needs public/css/components/inViewTooltip.css.
|
||||
*
|
||||
* Usage: <in-view-help :text="$p.t('category/phrase')" button-class="fs-5 text-body-secondary"></in-view-help>
|
||||
*/
|
||||
export default {
|
||||
name: 'InViewHelp',
|
||||
directives: {
|
||||
tooltip: InViewTooltip
|
||||
},
|
||||
props: {
|
||||
text: { type: String, default: '' },
|
||||
sticky: { type: Boolean, default: true },
|
||||
buttonClass: { type: [String, Array, Object], default: '' }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link p-0 fhc-in-view-help-btn"
|
||||
:class="buttonClass"
|
||||
:aria-label="$p.t('ui/hilfe')"
|
||||
v-tooltip="{ value: text, sticky }"
|
||||
>
|
||||
<i class="fa fa-circle-question" aria-hidden="true"></i>
|
||||
</button>`
|
||||
};
|
||||
@@ -106,13 +106,6 @@ export const CoreFilterCmpt = {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
// fields of the columns a preset is allowed to handle. Give the constant
|
||||
// columns here if the table builds dynamic columns at runtime. An empty
|
||||
// array gives the preset every column of the table.
|
||||
presetColumns: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
@@ -949,10 +942,6 @@ export const CoreFilterCmpt = {
|
||||
return res;
|
||||
}, {});
|
||||
},
|
||||
isColumnInPresetScope(columnField) {
|
||||
if (!this.$props.presetColumns.length) return true;
|
||||
return this.$props.presetColumns.includes(columnField);
|
||||
},
|
||||
afterTablePresetApplied(preset) {
|
||||
this.selectedFields = [...preset.displayedColumns];
|
||||
this.appliedPreset = { ...preset };
|
||||
@@ -984,7 +973,6 @@ export const CoreFilterCmpt = {
|
||||
|
||||
const displayedColumns = this.tabulator.columnManager.columns
|
||||
.filter((column) => column.field !== "collapse")
|
||||
.filter((column) => this.isColumnInPresetScope(column.field))
|
||||
.filter((column) => column.visible)
|
||||
.map((column) => column.field);
|
||||
|
||||
@@ -1041,13 +1029,12 @@ export const CoreFilterCmpt = {
|
||||
});
|
||||
|
||||
const activeSort = this.tabulator.getSorters()[0];
|
||||
const sort =
|
||||
activeSort && this.isColumnInPresetScope(activeSort.field)
|
||||
? {
|
||||
column: activeSort.field,
|
||||
direction: activeSort.dir,
|
||||
}
|
||||
: null;
|
||||
const sort = activeSort
|
||||
? {
|
||||
column: activeSort.field,
|
||||
direction: activeSort.dir,
|
||||
}
|
||||
: null;
|
||||
if (
|
||||
sort?.column !== this.appliedPreset.sort?.column ||
|
||||
sort?.direction !== this.appliedPreset.sort?.direction
|
||||
@@ -1161,7 +1148,6 @@ export const CoreFilterCmpt = {
|
||||
:presetsId="$props.presetsId"
|
||||
:tabulator="tabulator"
|
||||
:generalPresets="$props.generalPresets"
|
||||
:presetColumns="$props.presetColumns"
|
||||
ref="tablePresets"
|
||||
class="card-body collapse"
|
||||
></table-presets>
|
||||
|
||||
@@ -26,9 +26,6 @@ export default {
|
||||
presetsId: { type: String },
|
||||
tabulator: { type: Object },
|
||||
generalPresets: { type: Array, default: [] },
|
||||
// fields of the columns the preset is allowed to handle. An empty array
|
||||
// gives the preset every column of the table.
|
||||
presetColumns: { type: Array, default: [] },
|
||||
},
|
||||
emits: ["tablePresetApplied"],
|
||||
data: function () {
|
||||
@@ -165,10 +162,6 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isInPresetScope(columnField) {
|
||||
if (!this.$props.presetColumns.length) return true;
|
||||
return this.$props.presetColumns.includes(columnField);
|
||||
},
|
||||
getPresetIndex() {
|
||||
return this.presetIndexBeingApplied;
|
||||
},
|
||||
@@ -234,9 +227,9 @@ export default {
|
||||
}
|
||||
},
|
||||
generateNewPreset() {
|
||||
const columns = this.tabulator.columnManager.columns
|
||||
.filter((column) => column.field !== "collapse")
|
||||
.filter((column) => this.isInPresetScope(column.field));
|
||||
const columns = this.tabulator.columnManager.columns.filter(
|
||||
(column) => column.field !== "collapse",
|
||||
);
|
||||
|
||||
const displayedColumns = columns
|
||||
.filter((column) => column.visible)
|
||||
@@ -258,13 +251,12 @@ export default {
|
||||
);
|
||||
|
||||
const activeSort = this.tabulator.getSorters()[0];
|
||||
const sort =
|
||||
activeSort && this.isInPresetScope(activeSort.field)
|
||||
? {
|
||||
column: activeSort.field,
|
||||
direction: activeSort.dir,
|
||||
}
|
||||
: null;
|
||||
const sort = activeSort
|
||||
? {
|
||||
column: activeSort.field,
|
||||
direction: activeSort.dir,
|
||||
}
|
||||
: null;
|
||||
|
||||
this.newPreset = {
|
||||
displayedColumns,
|
||||
@@ -333,11 +325,8 @@ export default {
|
||||
preset.displayedColumns.length;
|
||||
preset.displayedColumns = preset.displayedColumns.filter(
|
||||
(columnField) => {
|
||||
return (
|
||||
this.isInPresetScope(columnField) &&
|
||||
validColumns.some(
|
||||
(column) => column.field === columnField,
|
||||
)
|
||||
return validColumns.some(
|
||||
(column) => column.field === columnField,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -376,11 +365,10 @@ export default {
|
||||
|
||||
if (preset.sort) {
|
||||
if (
|
||||
!this.isInPresetScope(preset.sort.column) ||
|
||||
!validColumns.some(
|
||||
(column) =>
|
||||
column.field === preset.sort.column &&
|
||||
column.headerSort !== false,
|
||||
column.headerSort,
|
||||
)
|
||||
) {
|
||||
preset.sort = null;
|
||||
@@ -419,10 +407,7 @@ export default {
|
||||
window.localStorage["tabulatorPreset-" + this.$props.presetsId] =
|
||||
preset.id ? "custom:" + preset.id : "general:" + preset.name;
|
||||
|
||||
this.$props.tabulator.modules.tablePresets.applyPreset(
|
||||
preset,
|
||||
this.$props.presetColumns,
|
||||
);
|
||||
this.$props.tabulator.modules.tablePresets.applyPreset(preset);
|
||||
|
||||
this.$emit("tablePresetApplied", { preset });
|
||||
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* v-tooltip with a panel that stays in view, also at the screen edge, in a modal and on a phone.
|
||||
*
|
||||
* - The panel is fixed and stays inside the viewport. It opens below the anchor, above it when
|
||||
* it fits only there or that side has more space. It grows away from the nearer viewport side.
|
||||
* - On an anchor wider than the panel, e.g. a long table cell, it opens at the mouse, not at the anchor edge.
|
||||
* - One panel at a time, appended to the body. A modal or a scrolling container cannot clip it.
|
||||
* - A mouse hover or a keyboard focus opens it. Escape, a click outside or a scroll closes it.
|
||||
*
|
||||
* Sticky, for several sentences or a list: a short label is read before the hover pause ends, a long text is not.
|
||||
* - After HOLD_DELAY_MS + HOLD_FILL_MS of hovering the panel sticks, the border fills up in the end.
|
||||
* A click on the anchor makes it stick at once. Touch has no hover: a tap toggles the sticky panel.
|
||||
* - A sticky panel stays until a click outside, Escape, a scroll or an opening bootstrap modal.
|
||||
*
|
||||
* The view needs public/css/components/inViewTooltip.css.
|
||||
*
|
||||
* Usage examples:
|
||||
*
|
||||
* 1) Directive, registered for the app or the component:
|
||||
* app.directive('tooltip', InViewTooltip) or directives: { tooltip: InViewTooltip }
|
||||
* <button v-tooltip="label"> a short label, hover only
|
||||
* <span v-tooltip.sticky="longText"> several sentences or a list
|
||||
* v-tooltip="{ value: text, disabled: bool, sticky: bool }", the modifier .top prefers the place above
|
||||
*
|
||||
* 2) Without a directive, e.g. a tabulator cell. The content is a text, a DOM node or a function:
|
||||
* { event: 'cellMouseEnter', handler: (e, cell) => hoverTooltip(cell.getElement(), () => buildNode(cell), { delay: 300, sticky: true, closeOnClick: true }) },
|
||||
* { event: 'cellMouseLeave', handler: (e, cell) => hideTooltip(cell.getElement()) }
|
||||
*
|
||||
* 3) A ? icon with a help text: components/InViewHelp.js
|
||||
*/
|
||||
|
||||
// hover time until a sticky panel sticks: a pause without motion filters out a pointer that only passes by,
|
||||
// then a short fill shows the pin. A long fill would draw the eye away from the text
|
||||
const HOLD_DELAY_MS = 1000
|
||||
const HOLD_FILL_MS = 400
|
||||
const HIDE_DELAY_MS = 150 // the pointer can cross the gap from the anchor into the panel
|
||||
const WATCH_MS = 250 // an anchor can leave the page without an unmount, e.g. in a hidden keep-alive tab
|
||||
const PANEL_MAX_WIDTH = 512 // px
|
||||
const PANEL_MARGIN = 16 // px between the panel and the viewport edge
|
||||
const PANEL_GAP = 6 // px between the anchor and the panel
|
||||
const PANEL_RADIUS = 5 // px, the inner radius of the panel border. The progress border follows it
|
||||
const PANEL_POINTER_INSET = 16 // px, the horizontal padding of the panel: the text starts below the mouse
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
let active = null // the open panel, only one at a time
|
||||
let pending = null // a delayed show
|
||||
let panelCount = 0
|
||||
|
||||
// the last pointer. Its type tells a tap from a mouse also for the mouse events of tabulator, a tap fires
|
||||
// emulated mouse events too: pointerover fires before them. Its place puts the panel next to the mouse
|
||||
const pointer = { type: 'mouse', x: null, y: null }
|
||||
const trackPointer = event => {
|
||||
pointer.type = event.pointerType
|
||||
pointer.x = event.clientX
|
||||
pointer.y = event.clientY
|
||||
}
|
||||
for (const name of ['pointerover', 'pointermove', 'pointerdown'])
|
||||
document.addEventListener(name, trackPointer, { capture: true, passive: true })
|
||||
|
||||
/**
|
||||
* Opens the panel for an anchor element.
|
||||
*
|
||||
* content: a text, a DOM node or a function that returns one of them.
|
||||
* options: prefer ('bottom' | 'top'), delay (ms), sticky (the hover timer pins the panel), pinned (sticks at once),
|
||||
* closeOnClick (a click on the anchor closes the panel, e.g. a table cell with a click action)
|
||||
*/
|
||||
export function showTooltip(anchor, content, options = {}) {
|
||||
cancelPending()
|
||||
if (!options.delay) return open(anchor, content, options)
|
||||
|
||||
pending = { anchor, timer: setTimeout(() => { pending = null; open(anchor, content, options) }, options.delay) }
|
||||
}
|
||||
|
||||
// for mouse events without a pointer type, e.g. the cell events of tabulator
|
||||
export function hoverTooltip(anchor, content, options) {
|
||||
if (pointer.type === 'mouse') showTooltip(anchor, content, options)
|
||||
}
|
||||
|
||||
// closes the panel of the anchor after HIDE_DELAY_MS, a sticky panel stays
|
||||
export function hideTooltip(anchor) {
|
||||
if (pending?.anchor === anchor) cancelPending()
|
||||
if (active?.anchor !== anchor || active.pinned) return
|
||||
|
||||
clearTimeout(active.hideTimer)
|
||||
active.hideTimer = setTimeout(close, HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
// closes the open panel, with an anchor only the panel of that anchor
|
||||
export function closeTooltip(anchor) {
|
||||
if (!anchor || pending?.anchor === anchor) cancelPending()
|
||||
if (!anchor || active?.anchor === anchor) close()
|
||||
}
|
||||
|
||||
function isTooltipPinned(anchor) {
|
||||
return active?.anchor === anchor && active.pinned
|
||||
}
|
||||
|
||||
// new content for an open panel, an empty text closes it
|
||||
function updateTooltip(anchor, content) {
|
||||
if (active?.anchor !== anchor) return
|
||||
if (!content) return close()
|
||||
|
||||
render(active.panel, content)
|
||||
place()
|
||||
}
|
||||
|
||||
function open(anchor, content, { prefer = 'bottom', sticky = false, pinned = false, closeOnClick = false }) {
|
||||
if (active?.anchor === anchor) {
|
||||
clearTimeout(active.hideTimer)
|
||||
if (pinned) pin()
|
||||
return
|
||||
}
|
||||
close()
|
||||
|
||||
sticky = sticky || pinned
|
||||
const panel = document.createElement('div')
|
||||
panel.id = 'fhc-in-view-tooltip-' + ++panelCount
|
||||
panel.className = 'fhc-in-view-tooltip'
|
||||
panel.setAttribute('role', 'tooltip')
|
||||
panel.innerHTML = '<div class="fhc-in-view-tooltip-body"></div>'
|
||||
if (sticky) {
|
||||
panel.style.setProperty('--fhc-in-view-tooltip-hold-delay', HOLD_DELAY_MS + 'ms')
|
||||
panel.style.setProperty('--fhc-in-view-tooltip-hold-fill', HOLD_FILL_MS + 'ms')
|
||||
panel.insertAdjacentHTML('beforeend', '<svg class="fhc-in-view-tooltip-progress" aria-hidden="true">'
|
||||
+ '<rect width="100%" height="100%" rx="' + PANEL_RADIUS + '"></rect></svg>')
|
||||
}
|
||||
render(panel, content)
|
||||
document.body.appendChild(panel)
|
||||
|
||||
// the pointer at the opening, while it is over the anchor. A keyboard focus has none
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const overAnchor = pointer.x !== null && pointer.x >= rect.left && pointer.x <= rect.right
|
||||
&& pointer.y >= rect.top && pointer.y <= rect.bottom
|
||||
|
||||
active = {
|
||||
anchor,
|
||||
panel,
|
||||
prefer,
|
||||
pointerX: overAnchor ? pointer.x : null,
|
||||
sticky,
|
||||
closeOnClick,
|
||||
pinned: false,
|
||||
describedBy: anchor.getAttribute('aria-describedby'),
|
||||
holdTimer: null,
|
||||
hideTimer: null,
|
||||
watchTimer: setInterval(() => {
|
||||
if (!anchor.isConnected || !anchor.getClientRects().length) close()
|
||||
}, WATCH_MS)
|
||||
}
|
||||
place()
|
||||
anchor.setAttribute('aria-describedby', panel.id)
|
||||
|
||||
if (pinned) {
|
||||
pin()
|
||||
} else if (sticky) {
|
||||
panel.classList.add('fhc-in-view-tooltip--holding')
|
||||
active.holdTimer = setTimeout(pin, HOLD_DELAY_MS + HOLD_FILL_MS)
|
||||
}
|
||||
|
||||
panel.addEventListener('pointerenter', onPanelEnter)
|
||||
panel.addEventListener('pointerleave', onPanelLeave)
|
||||
panel.addEventListener('click', onPanelClick)
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
// an anchor often opens a modal on click, the sticky panel must not cover it
|
||||
document.addEventListener('show.bs.modal', close)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', place)
|
||||
}
|
||||
|
||||
function pin() {
|
||||
if (!active || active.pinned || !active.sticky) return
|
||||
|
||||
clearTimeout(active.holdTimer)
|
||||
clearTimeout(active.hideTimer)
|
||||
active.pinned = true
|
||||
active.panel.classList.remove('fhc-in-view-tooltip--holding')
|
||||
active.panel.classList.add('fhc-in-view-tooltip--pinned')
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return
|
||||
|
||||
const { anchor, panel, describedBy } = active
|
||||
clearTimeout(active.holdTimer)
|
||||
clearTimeout(active.hideTimer)
|
||||
clearInterval(active.watchTimer)
|
||||
active = null
|
||||
|
||||
panel.remove()
|
||||
if (describedBy) anchor.setAttribute('aria-describedby', describedBy)
|
||||
else anchor.removeAttribute('aria-describedby')
|
||||
|
||||
document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown, true)
|
||||
document.removeEventListener('show.bs.modal', close)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', place)
|
||||
}
|
||||
|
||||
function cancelPending() {
|
||||
if (!pending) return
|
||||
|
||||
clearTimeout(pending.timer)
|
||||
pending = null
|
||||
}
|
||||
|
||||
function render(panel, content) {
|
||||
if (typeof content === 'function') content = content()
|
||||
|
||||
const body = panel.querySelector('.fhc-in-view-tooltip-body')
|
||||
if (content instanceof Node) body.replaceChildren(content)
|
||||
else body.textContent = content
|
||||
}
|
||||
|
||||
function place() {
|
||||
if (!active) return
|
||||
|
||||
const { anchor, panel, prefer } = active
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const viewWidth = document.documentElement.clientWidth
|
||||
const viewHeight = document.documentElement.clientHeight
|
||||
|
||||
// measure the natural size first
|
||||
panel.style.maxWidth = Math.min(PANEL_MAX_WIDTH, viewWidth - 2 * PANEL_MARGIN) + 'px'
|
||||
panel.style.maxHeight = ''
|
||||
panel.style.left = '0px'
|
||||
panel.style.top = '0px'
|
||||
panel.style.bottom = ''
|
||||
const width = panel.offsetWidth
|
||||
const height = panel.offsetHeight
|
||||
|
||||
const space = {
|
||||
bottom: viewHeight - rect.bottom - PANEL_GAP - PANEL_MARGIN,
|
||||
top: rect.top - PANEL_GAP - PANEL_MARGIN
|
||||
}
|
||||
const other = prefer === 'top' ? 'bottom' : 'top'
|
||||
let side = prefer
|
||||
if (space[prefer] < height) side = space[other] >= height || space[other] > space[prefer] ? other : prefer
|
||||
|
||||
// a panel at least as wide as the anchor starts at the anchor edge, grows away from the nearer viewport side
|
||||
// and spans the whole anchor. On a wider anchor that edge can be far from the mouse: the text starts below
|
||||
// the pointer, the clamp moves the panel to the left only at the right viewport edge
|
||||
const { pointerX } = active
|
||||
let left
|
||||
if (pointerX === null || width >= rect.width) left = rect.left + rect.width / 2 < viewWidth / 2 ? rect.left : rect.right - width
|
||||
else left = pointerX - PANEL_POINTER_INSET
|
||||
panel.style.left = clamp(left, PANEL_MARGIN, viewWidth - PANEL_MARGIN - width) + 'px'
|
||||
panel.style.maxHeight = space[side] + 'px'
|
||||
if (side === 'bottom') {
|
||||
panel.style.top = rect.bottom + PANEL_GAP + 'px'
|
||||
} else {
|
||||
panel.style.top = ''
|
||||
panel.style.bottom = viewHeight - rect.top + PANEL_GAP + 'px'
|
||||
}
|
||||
|
||||
if (!active.sticky) return
|
||||
|
||||
// the dash of the progress border is as long as the rounded rect along the inner edge of the 1 px border.
|
||||
// Rounded up, else a gap stays at its start: clientWidth drops the fraction of the width
|
||||
const box = panel.getBoundingClientRect()
|
||||
const perimeter = 2 * (box.width + box.height - 4) - (8 - 2 * Math.PI) * PANEL_RADIUS
|
||||
panel.style.setProperty('--fhc-in-view-tooltip-perimeter', Math.ceil(perimeter) + 1)
|
||||
}
|
||||
|
||||
function onPanelEnter() {
|
||||
cancelPending()
|
||||
clearTimeout(active.hideTimer)
|
||||
}
|
||||
|
||||
function onPanelLeave(event) {
|
||||
if (event.pointerType === 'mouse') hideTooltip(active.anchor)
|
||||
}
|
||||
|
||||
// a click into the panel closes it, a text selection keeps it
|
||||
function onPanelClick() {
|
||||
if (!String(window.getSelection())) close()
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
const { anchor, panel, closeOnClick } = active
|
||||
if (panel.contains(event.target)) return
|
||||
if (anchor.contains(event.target) && !closeOnClick) return
|
||||
|
||||
cancelPending()
|
||||
close()
|
||||
}
|
||||
|
||||
// Escape closes only the panel, not a modal around the anchor
|
||||
function onKeyDown(event) {
|
||||
if (event.key !== 'Escape') return
|
||||
|
||||
event.stopPropagation()
|
||||
close()
|
||||
}
|
||||
|
||||
// the panel is fixed, a scroll moves the anchor away from it. A scroll inside the panel keeps it
|
||||
function onScroll(event) {
|
||||
if (!active.panel.contains(event.target)) close()
|
||||
}
|
||||
|
||||
// reads the binding: "text" or { value, disabled, sticky }, the modifiers .sticky and .top
|
||||
function read(state, binding) {
|
||||
const value = binding.value
|
||||
const isObject = value && typeof value === 'object'
|
||||
state.text = (isObject ? (value.disabled ? '' : value.value) : value) ?? ''
|
||||
state.sticky = !!(binding.modifiers.sticky || isObject && value.sticky)
|
||||
state.prefer = binding.modifiers.top ? 'top' : 'bottom'
|
||||
}
|
||||
|
||||
export default {
|
||||
mounted(el, binding) {
|
||||
const state = el._fhcInViewTooltip = { pointerType: 'mouse' }
|
||||
read(state, binding)
|
||||
const show = options => {
|
||||
if (state.text) showTooltip(el, state.text, { prefer: state.prefer, sticky: state.sticky, ...options })
|
||||
}
|
||||
|
||||
state.listeners = {
|
||||
pointerenter: event => { if (event.pointerType === 'mouse') show() },
|
||||
pointerleave: event => { if (event.pointerType === 'mouse') hideTooltip(el) },
|
||||
pointerdown: event => { state.pointerType = event.pointerType },
|
||||
// a mouse click or a key pins a sticky panel at once, a tap toggles it
|
||||
click: () => {
|
||||
if (!state.sticky) return
|
||||
|
||||
if (state.pointerType !== 'mouse' && isTooltipPinned(el)) closeTooltip(el)
|
||||
else show({ pinned: true })
|
||||
|
||||
state.pointerType = 'mouse' // a key click has no pointerdown
|
||||
},
|
||||
focusin: event => { if (event.target.matches(':focus-visible')) show() },
|
||||
focusout: () => hideTooltip(el)
|
||||
}
|
||||
// the click captures: it pins the panel before the click action of the anchor runs, so a modal
|
||||
// that this action opens closes the panel again. The pointer events must not capture the children
|
||||
for (const [name, listener] of Object.entries(state.listeners)) el.addEventListener(name, listener, name === 'click')
|
||||
},
|
||||
updated(el, binding) {
|
||||
const state = el._fhcInViewTooltip
|
||||
const text = state.text
|
||||
read(state, binding)
|
||||
if (state.text !== text) updateTooltip(el, state.text)
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
const state = el._fhcInViewTooltip
|
||||
closeTooltip(el)
|
||||
for (const [name, listener] of Object.entries(state.listeners)) el.removeEventListener(name, listener, name === 'click')
|
||||
delete el._fhcInViewTooltip
|
||||
}
|
||||
}
|
||||
@@ -8,36 +8,27 @@ export default class TablePresetsModule extends Module {
|
||||
super(table);
|
||||
}
|
||||
|
||||
// presetColumns limits the preset to the given column fields. A table with dynamic
|
||||
// columns gives the fields of its constant columns here.
|
||||
applyPreset(preset, presetColumns = []) {
|
||||
if (presetColumns.length) {
|
||||
this.applyColumnsInScope(preset, presetColumns);
|
||||
} else {
|
||||
let columnsConfig = this.table.options.columns;
|
||||
let reorderedColumnsConfig = [];
|
||||
preset.displayedColumns.forEach((columnField) => {
|
||||
let columnConfig = columnsConfig.find(
|
||||
(column) => column.field === columnField,
|
||||
);
|
||||
if (!columnConfig) return;
|
||||
columnConfig.visible = true;
|
||||
reorderedColumnsConfig.push(columnConfig);
|
||||
});
|
||||
|
||||
let hiddenColumnsConfig = columnsConfig
|
||||
.filter(
|
||||
(column) =>
|
||||
!preset.displayedColumns.includes(column.field),
|
||||
)
|
||||
.map((column) => {
|
||||
column.visible = false;
|
||||
return column;
|
||||
});
|
||||
this.table.setColumns(
|
||||
reorderedColumnsConfig.concat(hiddenColumnsConfig),
|
||||
applyPreset(preset) {
|
||||
let columnsConfig = this.table.options.columns;
|
||||
let reorderedColumnsConfig = [];
|
||||
preset.displayedColumns.forEach((columnField) => {
|
||||
let columnConfig = columnsConfig.find(
|
||||
(column) => column.field === columnField,
|
||||
);
|
||||
}
|
||||
if (!columnConfig) return;
|
||||
columnConfig.visible = true;
|
||||
reorderedColumnsConfig.push(columnConfig);
|
||||
});
|
||||
|
||||
let hiddenColumnsConfig = columnsConfig
|
||||
.filter((column) => !preset.displayedColumns.includes(column.field))
|
||||
.map((column) => {
|
||||
column.visible = false;
|
||||
return column;
|
||||
});
|
||||
this.table.setColumns(
|
||||
reorderedColumnsConfig.concat(hiddenColumnsConfig),
|
||||
);
|
||||
|
||||
this.table.clearHeaderFilter();
|
||||
if (preset.headerFilters) {
|
||||
@@ -70,44 +61,4 @@ export default class TablePresetsModule extends Module {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The preset orders and hides the columns of the scope only. A column outside the
|
||||
// scope keeps its position, because the table does not show it in every state.
|
||||
applyColumnsInScope(preset, presetColumns) {
|
||||
const displayedColumns = preset.displayedColumns ?? [];
|
||||
|
||||
// a displayed column takes the rank of the preset. A column that the preset
|
||||
// hides has no rank and keeps the current order at the end of its block.
|
||||
const rank = (columnConfig) => {
|
||||
const index = displayedColumns.indexOf(columnConfig.field);
|
||||
return index === -1 ? displayedColumns.length : index;
|
||||
};
|
||||
|
||||
let columnsConfig = [];
|
||||
let scopeBlock = [];
|
||||
|
||||
// the sort runs for each block of neighbouring scope columns. This way a scope
|
||||
// column never moves over a column outside the scope.
|
||||
const addScopeBlock = () => {
|
||||
scopeBlock.sort((a, b) => rank(a) - rank(b));
|
||||
columnsConfig = columnsConfig.concat(scopeBlock);
|
||||
scopeBlock = [];
|
||||
};
|
||||
|
||||
this.table.getColumnDefinitions().forEach((columnConfig) => {
|
||||
if (!presetColumns.includes(columnConfig.field)) {
|
||||
addScopeBlock();
|
||||
columnsConfig.push(columnConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
columnConfig.visible = displayedColumns.includes(
|
||||
columnConfig.field,
|
||||
);
|
||||
scopeBlock.push(columnConfig);
|
||||
});
|
||||
addScopeBlock();
|
||||
|
||||
this.table.setColumns(columnsConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ require_once('dbupdate_3.4/example.php');
|
||||
require_once('dbupdate_3.4/example2.php');
|
||||
...
|
||||
*/
|
||||
//require_once('dbupdate_3.4/25003_notenimport_nachpruefung.php');
|
||||
require_once('dbupdate_3.4/dbupdate_dashboard.php');
|
||||
require_once('dbupdate_3.4/26173_index_webservicelog.php');
|
||||
require_once('dbupdate_3.4/24682_reihungstest_zugangscode_fuer_login.php');
|
||||
@@ -102,7 +103,8 @@ require_once('dbupdate_3.4/76160_lvv_favorites.php');
|
||||
require_once('dbupdate_3.4/77080_tabulator_presets_table.php');
|
||||
require_once('dbupdate_3.4/78292_gehaltsanpassungtyp.php');
|
||||
require_once('dbupdate_3.4/54225_reihungstest_ablauf_fuer_quereinsteiger.php');
|
||||
|
||||
require_once('dbupdate_3.4/77757_widgets_berechtigungen.php');
|
||||
require_once('dbupdate_3.4/83575_IDAM_Exclude_Degree_Program_from_Student_Sync.php');
|
||||
|
||||
// *** Pruefung und hinzufuegen der neuen Attribute und Tabellen
|
||||
echo '<H2>Pruefe Tabellen und Attribute!</H2>';
|
||||
@@ -396,7 +398,7 @@ $tabellen=array(
|
||||
"public.tbl_statistik" => array("statistik_kurzbz","bezeichnung","url","gruppe","sql","content_id","insertamum","insertvon","updateamum","updatevon","berechtigung_kurzbz","publish","preferences"),
|
||||
"public.tbl_student" => array("student_uid","matrikelnr","prestudent_id","studiengang_kz","semester","verband","gruppe","updateamum","updatevon","insertamum","insertvon","ext_id"),
|
||||
"public.tbl_studentlehrverband" => array("student_uid","studiensemester_kurzbz","studiengang_kz","semester","verband","gruppe","updateamum","updatevon","insertamum","insertvon","ext_id"),
|
||||
"public.tbl_studiengang" => array("studiengang_kz","kurzbz","kurzbzlang","typ","bezeichnung","english","farbe","email","telefon","max_semester","max_verband","max_gruppe","erhalter_kz","bescheid","bescheidbgbl1","bescheidbgbl2","bescheidgz","bescheidvom","orgform_kurzbz","titelbescheidvom","aktiv","ext_id","zusatzinfo_html","moodle","sprache","testtool_sprachwahl","studienplaetze","oe_kurzbz","lgartcode","mischform","projektarbeit_note_anzeige", "onlinebewerbung","melderelevant","foerderrelevant","standort_code","melde_studiengang_kz"),
|
||||
"public.tbl_studiengang" => array("studiengang_kz","kurzbz","kurzbzlang","typ","bezeichnung","english","farbe","email","telefon","max_semester","max_verband","max_gruppe","erhalter_kz","bescheid","bescheidbgbl1","bescheidbgbl2","bescheidgz","bescheidvom","orgform_kurzbz","titelbescheidvom","aktiv","ext_id","zusatzinfo_html","moodle","sprache","testtool_sprachwahl","studienplaetze","oe_kurzbz","lgartcode","mischform","projektarbeit_note_anzeige", "onlinebewerbung","melderelevant","foerderrelevant","standort_code","melde_studiengang_kz", "account_creation"),
|
||||
"public.tbl_studiengangstyp" => array("typ","bezeichnung","beschreibung","bezeichnung_mehrsprachig"),
|
||||
"public.tbl_studienjahr" => array("studienjahr_kurzbz","bezeichnung"),
|
||||
"public.tbl_studiensemester" => array("studiensemester_kurzbz","bezeichnung","start","ende","studienjahr_kurzbz","ext_id","beschreibung","onlinebewerbung"),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
/* Copyright (C) 2026 fhcomplete.org
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as
|
||||
* published by the Free Software Foundation; either version 2 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
|
||||
*
|
||||
* Beschreibung:
|
||||
* Dashboard Widgets: Verknüpfung einer (einzelnen) Berechtigung pro Widget.
|
||||
* Neue Spalte dashboard.tbl_widget.berechtigung_kurzbz mit FK auf
|
||||
* system.tbl_berechtigung. Ist die Spalte NULL, erfordert das Widget keine
|
||||
* gesonderte Berechtigung. Benutzer denen die verknüpfte Berechtigung fehlt,
|
||||
* bekommen das Widget nicht mehr zur Auswahl angezeigt bzw. sehen (falls bereits
|
||||
* am Dashboard) einen "Fehlende Berechtigung"-Screen.
|
||||
*/
|
||||
if (! defined('DB_NAME')) exit('No direct script access allowed');
|
||||
|
||||
// Add column dashboard.tbl_widget.berechtigung_kurzbz (FK => system.tbl_berechtigung)
|
||||
if ($result = @$db->db_query("
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'dashboard'
|
||||
AND table_name = 'tbl_widget'
|
||||
AND column_name = 'berechtigung_kurzbz';"))
|
||||
{
|
||||
if ($db->db_num_rows($result) == 0)
|
||||
{
|
||||
$qry = "
|
||||
ALTER TABLE dashboard.tbl_widget
|
||||
ADD COLUMN berechtigung_kurzbz VARCHAR(32) NULL;
|
||||
|
||||
ALTER TABLE dashboard.tbl_widget
|
||||
ADD CONSTRAINT tbl_widget_berechtigung_fk
|
||||
FOREIGN KEY (berechtigung_kurzbz)
|
||||
REFERENCES system.tbl_berechtigung (berechtigung_kurzbz)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL;";
|
||||
|
||||
if (!$db->db_query($qry))
|
||||
{
|
||||
echo '<strong>dashboard.tbl_widget: '.$db->db_last_error().'</strong><br>';
|
||||
}
|
||||
else
|
||||
{
|
||||
echo 'dashboard.tbl_widget: Spalte berechtigung_kurzbz (FK system.tbl_berechtigung) hinzugefuegt<br>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
if (! defined('DB_NAME')) exit('No direct script access allowed');
|
||||
|
||||
// Add column account_creation to public.tbl_studiengang
|
||||
if( !@$db->db_query('SELECT account_creation FROM public.tbl_studiengang LIMIT 1'))
|
||||
{
|
||||
$qry = 'ALTER TABLE public.tbl_studiengang ADD COLUMN IF NOT EXISTS account_creation BOOLEAN NOT NULL DEFAULT TRUE;';
|
||||
|
||||
$qry .= "COMMENT ON COLUMN public.tbl_studiengang.account_creation IS 'Flag for the student accounts creation on IDAM';";
|
||||
|
||||
if (!$db->db_query($qry))
|
||||
echo '<strong>public.tbl_studiengang.account_creation: '.$db->db_last_error().'</strong><br>';
|
||||
else
|
||||
echo '<br>public.tbl_studiengang.account_creation: Neue Spalte hinzugefügt';
|
||||
}
|
||||
|
||||
+80
-6103
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
INSERT INTO lehre.tbl_projektarbeit
|
||||
(projektarbeit_id, projekttyp_kurzbz, titel, lehreinheit_id, student_uid, firma_id, note, punkte, beginn, ende, faktor, freigegeben, gesperrtbis, stundensatz, themenbereich, anmerkung, updateamum, updatevon, insertamum, insertvon, ext_id, gesamtstunden, titel_english, sprache, seitenanzahl, abgabedatum, kontrollschlagwoerter, schlagwoerter, schlagwoerter_en, abstract, abstract_en, "final") VALUES
|
||||
(1, 'Bachelor', 'Auswirkung der Sonneneinstrahlung auf die Gesundheit', 46111, 's46b413', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true),
|
||||
(2, 'Bachelor', 'Neue Unterrichtsmethoden mit KI', 46111, 's46b414', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true),
|
||||
(3, 'Bachelor', 'Betrugsbekämpfung bei Anwesenheitskontrolle', 46111, 's46b415', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true),
|
||||
(4, 'Bachelor', 'Neuronale Netze', 46111, 's46b416', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true)
|
||||
(projektarbeit_id, projekttyp_kurzbz, titel, lehreinheit_id, student_uid, firma_id, note, punkte, beginn, ende, faktor, freigegeben, gesperrtbis, stundensatz, themenbereich, anmerkung, updateamum, updatevon, insertamum, insertvon, ext_id, gesamtstunden, titel_english, sprache, seitenanzahl, abgabedatum, kontrollschlagwoerter, schlagwoerter, schlagwoerter_en, abstract, abstract_en, "final", lehrveranstaltung_id, studiensemester_kurzbz) VALUES
|
||||
(1, 'Bachelor', 'Auswirkung der Sonneneinstrahlung auf die Gesundheit', 46111, 's46b413', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true, 4611, NearestWintersemester(1)),
|
||||
(2, 'Bachelor', 'Neue Unterrichtsmethoden mit KI', 46111, 's46b414', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true, 4611, NearestWintersemester(1)),
|
||||
(3, 'Bachelor', 'Betrugsbekämpfung bei Anwesenheitskontrolle', 46111, 's46b415', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true, 4611, NearestWintersemester(1)),
|
||||
(4, 'Bachelor', 'Neuronale Netze', 46111, 's46b416', NULL, NULL, NULL, NULL, NULL, NULL, true, NULL, NULL, NULL, NULL, NULL, NULL, '2025-12-12 14:06:10.000', 'anondata', NULL, 3.0000, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, true, 4611, NearestWintersemester(1))
|
||||
;
|
||||
|
||||
INSERT INTO lehre.tbl_projektbetreuer
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user