aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Hackl <hackl@data-quest.de>2024-11-11 07:33:55 +0000
committerThomas Hackl <hackl@data-quest.de>2024-11-11 07:33:55 +0000
commit6931cfc86f6430853caab7aa6b364d8b8809f913 (patch)
treeba7c6618bd427c4afc5f2203388556d31625dc0b
parent55891e3ccfaaa43cc1d5cf8043d4d2fb323f097d (diff)
Resolve "ContentBar 2.0"
Closes #4244 Merge request studip/studip!3128
-rw-r--r--app/controllers/course/wiki.php60
-rw-r--r--app/controllers/oer/market.php9
-rw-r--r--app/views/course/wiki/edit.php4
-rw-r--r--app/views/course/wiki/page.php3
-rw-r--r--app/views/course/wiki/version.php8
-rw-r--r--app/views/oer/market/details.php7
-rw-r--r--lib/classes/Debug/VueCollector.php17
-rw-r--r--lib/classes/Icon.php7
-rw-r--r--lib/classes/VueApp.php23
-rw-r--r--lib/models/WikiPage.php26
-rw-r--r--lib/modules/CoreWiki.php53
-rw-r--r--resources/assets/javascripts/lib/actionmenu.js6
-rw-r--r--resources/assets/stylesheets/scss/contentbar.scss4
-rw-r--r--resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss118
-rw-r--r--resources/assets/stylesheets/scss/courseware/layouts/tabs.scss2
-rw-r--r--resources/assets/stylesheets/scss/responsive.scss112
-rw-r--r--resources/assets/stylesheets/scss/table_of_contents.scss73
-rw-r--r--resources/studip.d.ts21
-rw-r--r--resources/vue/components/ContentBar.vue163
-rw-r--r--resources/vue/components/ContentBarBreadcrumbs.vue78
-rw-r--r--resources/vue/components/ContentBarTableOfContents.vue63
-rw-r--r--resources/vue/components/ContentBarTocItemList.vue33
-rw-r--r--resources/vue/components/WikiEditor.vue22
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareRibbon.vue207
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue50
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue16
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue128
-rw-r--r--resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue6
-rw-r--r--resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue17
-rw-r--r--resources/vue/components/courseware/structural-element/structural-element-components.js6
-rw-r--r--resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue18
-rw-r--r--resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue18
-rw-r--r--resources/vue/components/courseware/toolbar/CoursewareToolbar.vue2
-rw-r--r--resources/vue/components/responsive/ResponsiveContentBar.vue63
-rw-r--r--resources/vue/components/table-of-contents.ts19
-rw-r--r--resources/vue/mixins/courseware/export.js5
-rw-r--r--resources/vue/store/StudipStore.js21
-rw-r--r--resources/vue/store/courseware/courseware-public.module.js13
-rw-r--r--resources/vue/store/courseware/courseware.module.js8
-rw-r--r--templates/vue-app.php7
40 files changed, 949 insertions, 567 deletions
diff --git a/app/controllers/course/wiki.php b/app/controllers/course/wiki.php
index 8e72f8a..926f641 100644
--- a/app/controllers/course/wiki.php
+++ b/app/controllers/course/wiki.php
@@ -30,7 +30,7 @@ class Course_WikiController extends AuthenticatedController
}
Navigation::activateItem('/course/wiki/start');
- $this->page = new WikiPage($page_id);
+ $this->page = WikiPage::find($page_id) ?: new WikiPage();
$this->validateWikiPage($this->page, $this->range);
$sidebar = Sidebar::Get();
@@ -97,6 +97,13 @@ class Course_WikiController extends AuthenticatedController
$sidebar->addWidget($actions);
}
+ $contentbarProps = [
+ 'icon' => 'wiki',
+ 'isContentBar' => true
+ ];
+
+ $toc = CoreWiki::getTOC($this->page);
+
if (!$this->page->isNew()) {
//then the wiki is not empty
$search = new SearchWidget($this->searchURL());
@@ -116,12 +123,18 @@ class Course_WikiController extends AuthenticatedController
Icon::create('file-pdf')
);
$sidebar->addWidget($exports);
+
+ $contentbarProps['toc'] = $toc;
}
- $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID);
- $this->contentbar = ContentBar::get()
- ->setTOC(CoreWiki::getTOC($this->page))
- ->setIcon(Icon::create('wiki'));
+ // Content bar
+ $this->contentBarVueApp = \Studip\VueApp::create('ContentBar')
+ ->withProps($contentbarProps)
+ ->withComponent('ContentBarBreadcrumbs')
+ ->withSlot('breadcrumb-list',
+ sprintf("<content-bar-breadcrumbs :toc='%s'/>", json_encode($toc))
+ );
+
if (!$this->page->isNew()) {
$author = _('unbekannt');
if ($this->page->user) {
@@ -132,8 +145,8 @@ class Course_WikiController extends AuthenticatedController
);
}
- $this->contentbar->setInfoHTML(sprintf(
- _('Version %1$s, geändert von %2$s <br> am %3$s'),
+ $this->contentBarVueApp = $this->contentBarVueApp->withSlot('info-text', sprintf(
+ _('Version %1$s, geändert von %2$s am %3$s'),
$this->page->versionnumber,
$author,
date('d.m.Y H:i:s', $this->page['chdate'])
@@ -167,13 +180,7 @@ class Course_WikiController extends AuthenticatedController
);
}
}
- $action_menu->addLink(
- '#',
- _('Als Vollbild anzeigen'),
- Icon::create('screen-full'),
- ['class' => 'fullscreen-trigger hidden-medium-down']
- );
- $this->contentbar->setActionMenu($action_menu);
+ $this->contentBarVueApp = $this->contentBarVueApp->withSlot('menu', $action_menu->render());
}
}
@@ -478,10 +485,6 @@ class Course_WikiController extends AuthenticatedController
ORDER BY Nachname, Vorname",
[$page->id]
);
- $this->contentbar = ContentBar::get()
- ->setTOC(CoreWiki::getTOC($page))
- ->setIcon(Icon::create('wiki'))
- ->setInfoHTML(_('Zuletzt gespeichert') .': '. '<span class="wiki-last-edited-' . $this->page->id . '"></span>');
}
public function apply_editing_action(WikiPage $page)
@@ -715,15 +718,24 @@ class Course_WikiController extends AuthenticatedController
Navigation::activateItem('/course/wiki/start');
Sidebar::Get()->addWidget($this->getViewsWidget($version->page, 'history'));
- $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID);
- $this->contentbar = ContentBar::get()
- ->setTOC(CoreWiki::getTOC($version->page))
- ->setIcon(Icon::create('wiki'))
- ->setInfoHTML(sprintf(
+
+ $toc = CoreWiki::getTOC($version->page);
+ $this->contentBarVueApp = \Studip\VueApp::create('ContentBar')
+ ->withProps([
+ 'icon' => 'wiki',
+ 'isContentBar' => true,
+ 'toc' => $toc,
+ ])
+ ->withSlot('info-text', sprintf(
_('Version %1$s vom %2$s'),
$version->versionnumber,
date('d.m.Y H:i:s', $version['mkdate'])
- ));
+ ))
+ ->withComponent('ContentBarBreadcrumbs')
+ ->withSlot('breadcrumb-list', sprintf(
+ "<content-bar-breadcrumbs :toc='%s'/>",
+ json_encode($toc))
+ );
}
public function blame_action(WikiPage $page)
diff --git a/app/controllers/oer/market.php b/app/controllers/oer/market.php
index 72133d7..69030c2 100644
--- a/app/controllers/oer/market.php
+++ b/app/controllers/oer/market.php
@@ -328,10 +328,11 @@ class Oer_MarketController extends StudipController
}
}
- $this->contentbar = ContentBar::get()
- ->setTOC(new TOCItem($this->material['name']))
- ->setInfoHTML(htmlReady($infotext))
- ->setIcon(Icon::create('oer-campus'));
+ $this->contentBarVueApp = \Studip\VueApp::create('ContentBar')->withProps([
+ 'title' => $this->material['name'],
+ 'icon' => 'oer-campus',
+ 'isContentBar' => true,
+ ])->withSlot('info-text', htmlReady($infotext));
}
public function embed_action($material_id)
diff --git a/app/views/course/wiki/edit.php b/app/views/course/wiki/edit.php
index 983a02c..40e0111 100644
--- a/app/views/course/wiki/edit.php
+++ b/app/views/course/wiki/edit.php
@@ -3,12 +3,9 @@
* @var WikiPage $page
* @var Course_WikiController $controller
* @var WikiOnlineEditingUser $me_online
- * @var ContentBar $contentbar
*/
?>
-<?= $contentbar ?>
-
<?= Studip\VueApp::create('WikiEditor')
->withProps([
'cancel-url' => $controller->leave_editingURL($page),
@@ -18,5 +15,6 @@
'page-id' => (int) $page->id,
'save-url' => $controller->saveURL($page),
'users' => $page->getOnlineUsers(),
+ 'toc' => CoreWiki::getTOC($page),
])
?>
diff --git a/app/views/course/wiki/page.php b/app/views/course/wiki/page.php
index e3c67cf..21ae031 100644
--- a/app/views/course/wiki/page.php
+++ b/app/views/course/wiki/page.php
@@ -4,10 +4,11 @@
* @var string $edit_perms
* @var Context $range
* @var Course_WikiController $controller
+ * @var \Studip\VueApp $contentBarVueApp
*/
-echo $contentbar;
?>
+<?= $contentBarVueApp->render() ?>
<? if ($page->isEditable()) : ?>
<form action="<?= $controller->delete($page->id) ?>" method="post" id="delete_page">
diff --git a/app/views/course/wiki/version.php b/app/views/course/wiki/version.php
index 26094ae..177d7d0 100644
--- a/app/views/course/wiki/version.php
+++ b/app/views/course/wiki/version.php
@@ -1,4 +1,10 @@
-<?= $contentbar ?>
+<?php
+/**
+ * @var \Studip\VueApp $contentBarVueApp
+ */
+?>
+
+<?= $contentBarVueApp->render() ?>
<div class="wiki_page_content">
<?= wikiReady($version['content']) ?>
diff --git a/app/views/oer/market/details.php b/app/views/oer/market/details.php
index 9e1c60b..c05bbdf 100644
--- a/app/views/oer/market/details.php
+++ b/app/views/oer/market/details.php
@@ -1,4 +1,9 @@
-<?= $contentbar ?>
+<?php
+/**
+ * @var Studip\VueApp $contentBarVueApp
+ */
+?>
+<?= $contentBarVueApp->render() ?>
<? $url = $material->getDownloadUrl() ?>
diff --git a/lib/classes/Debug/VueCollector.php b/lib/classes/Debug/VueCollector.php
index a2d90f3..0dd479b 100644
--- a/lib/classes/Debug/VueCollector.php
+++ b/lib/classes/Debug/VueCollector.php
@@ -45,6 +45,23 @@ final class VueCollector extends DataCollector implements Renderable
}
}
+ $slots = $this->app->getSlots();
+ if (count($slots) > 0) {
+ ksort($slots);
+
+ $data['== SLOTS =='] = count($slots) . ' items';
+ foreach ($slots as $key => $value) {
+ $data[$key] = $this->dumpVar($value);
+ }
+ }
+
+ $components = $this->app->getComponents();
+ ksort($components);
+ $data['== COMPONENTS =='] = count($components) . ' items';
+ foreach ($components as $value) {
+ $data[$value] = '';
+ }
+
return $data;
}
diff --git a/lib/classes/Icon.php b/lib/classes/Icon.php
index 6c586a0..fd2a25b 100644
--- a/lib/classes/Icon.php
+++ b/lib/classes/Icon.php
@@ -9,7 +9,7 @@
* @license GPL2 or any later version
* @since 3.2
*/
-class Icon
+class Icon implements JsonSerializable
{
const SVG = 1;
const CSS_BACKGROUND = 4;
@@ -196,6 +196,11 @@ class Icon
return $this->asImg();
}
+ public function jsonSerialize(): mixed
+ {
+ return get_object_vars($this);
+ }
+
/**
* Renders the icon inside an img html tag.
*
diff --git a/lib/classes/VueApp.php b/lib/classes/VueApp.php
index 76c9a52..c26b011 100644
--- a/lib/classes/VueApp.php
+++ b/lib/classes/VueApp.php
@@ -43,6 +43,7 @@ final class VueApp implements Stringable
private array $slots = [];
private array $stores = [];
private array $storeData = [];
+ private array $components = [];
/**
* Private constructor since we want to enforce the use of VueApp::create().
@@ -50,6 +51,7 @@ final class VueApp implements Stringable
private function __construct(
private readonly string $base_component
) {
+ $this->components[] = $base_component;
}
/**
@@ -153,12 +155,30 @@ final class VueApp implements Stringable
}
/**
+ * Registers a component for use e.g. in slots.
+ */
+ public function withComponent(string $component): VueApp
+ {
+ $clone = clone $this;
+ $clone->components[] = $component;
+ return $clone;
+ }
+
+ /**
+ * Returns all components
+ */
+ public function getComponents(): array
+ {
+ return $this->components;
+ }
+
+ /**
* Returns the template to render the vue app
*/
public function getTemplate(): Template
{
$data = [
- 'components' => [$this->base_component],
+ 'components' => $this->components,
];
if (count($this->stores) > 0) {
@@ -174,6 +194,7 @@ final class VueApp implements Stringable
$template->attributes = ['data-vue-app' => json_encode($data)];
$template->props = $this->getPreparedProps();
$template->storeData = $this->storeData;
+ $template->slots = $this->getSlots();
return $template;
}
diff --git a/lib/models/WikiPage.php b/lib/models/WikiPage.php
index 817dfae..bb41d1b 100644
--- a/lib/models/WikiPage.php
+++ b/lib/models/WikiPage.php
@@ -202,32 +202,6 @@ class WikiPage extends SimpleORMap implements PrivacyObject
/**
- * Returns the start page of a wiki for a given course. The start page has
- * the keyword 'WikiWikiWeb'.
- *
- * @param string $range_id Course id
- * @return WikiPage
- */
- public static function getStartPage($range_id): WikiPage
- {
- $page_id = CourseConfig::get($range_id)->WIKI_STARTPAGE_ID;
-
- if ($page_id) {
- return self::find($page_id);
- }
-
- $page = new WikiPage();
- $page->setValue('content', _('Dieses Wiki ist noch leer.'));
- if ($page->isEditable()) {
- $page->setValue(
- 'content',
- $page->getValue('content') . ' ' . _("Bearbeiten Sie es!\nNeue Seiten oder Links werden einfach durch Eingeben von [nop][[Wikinamen]][/nop] in doppelten eckigen Klammern angelegt.")
- );
- }
- return $page;
- }
-
- /**
* Export available data of a given user into a storage object
* (an instance of the StoredUserData class) for that user.
*
diff --git a/lib/modules/CoreWiki.php b/lib/modules/CoreWiki.php
index a447451..b544af3 100644
--- a/lib/modules/CoreWiki.php
+++ b/lib/modules/CoreWiki.php
@@ -178,28 +178,45 @@ class CoreWiki extends CorePlugin implements StudipModule
/**
- * Generates a page hierarchy for table of contents/breadcrumbs.
- * @return TOCItem
+ * Generates a TOCItem tree containing all pages in the currently opened wiki
+ * for use in table of contents/breadcrumbs.
+ * To prevent cyclic data references, the TOCItems in the tree do not contain
+ * references to their parent pages.
+ * This allows the resultant TOCItem to be serialized via json_decode for
+ * use in Vue.
+ *
+ * @param $activePage WikiPage The page that the user has currently navigated to.
+ * @return TOCItem A TOCItem for the root of the wiki and all of its descendants.
*/
- public static function getTOC($page, $first = true): TOCItem
+ public static function getTOC(WikiPage $activePage): TOCItem
{
- $root = new TOCItem(
- ($page && ($page->isNew() || $page->name === 'WikiWikiWeb'))
- ? _('Wiki-Startseite')
- : $page->name
- );
- $root->setURL(URLHelper::getURL('dispatch.php/course/wiki/page/'.$page->id));
- if ($page->name == 'WikiWikiWeb' || $page->id == CourseConfig::get($page->range_id)->WIKI_STARTPAGE_ID) {
- $root->setIcon(Icon::create('wiki'));
- }
- $root->setActive($first);
+ $rootId = CourseConfig::get(Context::getId())->WIKI_STARTPAGE_ID;
+ $rootPage = WikiPage::find($rootId) ?: $activePage;
- if ($page->parent) {
- $parent = self::getTOC($page->parent, false);
- $root->setParent($parent);
- }
+ $rootToc = self::getTOCRecursive($rootPage, $activePage->page_id);
+ $rootToc->setTitle(_('Wiki-Startseite'));
+ $rootToc->setIcon(Icon::create('wiki'));
+ return $rootToc;
+ }
- return $root;
+ /**
+ * Using a recursive depth-first traversal of the wiki page hierarchy,
+ * create a TOCItem tree starting at the given $page.
+ *
+ * @param WikiPage $page The currently visited page in the traversal.
+ * @param int|null $active_page_id The id of the page that the user has navigated to.
+ * @return TOCItem A TOCItem for the given $page and all of its descendants
+ */
+ private static function getTOCRecursive(WikiPage $page, int|null $active_page_id): TOCItem
+ {
+ $toc = new TOCItem($page->isNew() ? _('Wiki-Startseite') : $page->name);
+ $toc->setURL($page->isNew() ? URLHelper::getURL('dispatch.php/course/wiki/page') : URLHelper::getURL('dispatch.php/course/wiki/page/' . $page->id));
+ $toc->setActive($page->page_id === $active_page_id);
+ foreach ($page->children as $child) {
+ $childToc = self::getTOCRecursive($child, $active_page_id);
+ $toc->children[] = $childToc;
+ }
+ return $toc;
}
}
diff --git a/resources/assets/javascripts/lib/actionmenu.js b/resources/assets/javascripts/lib/actionmenu.js
index a81f78a..da85e33 100644
--- a/resources/assets/javascripts/lib/actionmenu.js
+++ b/resources/assets/javascripts/lib/actionmenu.js
@@ -103,7 +103,11 @@ class ActionMenu
// Reposition the menu?
if (position) {
- let parents = getScrollableParents(this.element, menu_width, menu_height);
+ let parents = getScrollableParents(this.element, menu_width, menu_height)
+ // Prevent us from appending the actionmenu outside of the <body>.
+ // (If it's appended outside of <body>, some CSS rules will not
+ // be applied, and the Z-ordering will be incorrect.)
+ .filter(parent => parent !== document.documentElement);
if (parents.length > 0) {
const form = this.element.closest('form');
if (form) {
diff --git a/resources/assets/stylesheets/scss/contentbar.scss b/resources/assets/stylesheets/scss/contentbar.scss
index 3b0aff3..338737b 100644
--- a/resources/assets/stylesheets/scss/contentbar.scss
+++ b/resources/assets/stylesheets/scss/contentbar.scss
@@ -2,7 +2,6 @@
background-color: var(--dark-gray-color-10);
display: flex;
flex-wrap: nowrap;
- height: auto;
align-items: center;
justify-content: flex-start;
margin-bottom: 15px;
@@ -85,6 +84,9 @@
margin: 0 7px;
@-moz-document url-prefix() {
+ &.contentbar-toc-wrapper {
+ margin-top: -4px;
+ }
&.contentbar-action-menu-wrapper {
margin-top: 2px;
}
diff --git a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss
index 457cd4d..28ecfbe 100644
--- a/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss
+++ b/resources/assets/stylesheets/scss/courseware/layouts/ribbon.scss
@@ -14,17 +14,13 @@ $consum_ribbon_width: calc(100% - 58px);
}
.cw-ribbon-wrapper-consume {
- position: fixed;
- padding-top: 15px;
+ padding-top: 7px;
padding-bottom: 15px;
background-color: var(--white);
- width: $consum_ribbon_width;
- height: 46px;
z-index: 42;
}
.cw-ribbon-consume-bottom {
- position: fixed;
top: 75px;
height: 15px;
left: 0;
@@ -56,38 +52,46 @@ $consum_ribbon_width: calc(100% - 58px);
}
.cw-ribbon {
+ position: relative;
display: flex;
flex-wrap: wrap;
- height: auto;
- min-height: 30px;
+ align-items: center;
+ height: 60px;
+ min-height: 60px;
margin-bottom: 15px;
- padding: 1em;
+ padding: 0 2em;
justify-content: space-between;
background-color: var(--dark-gray-color-10);
&.cw-ribbon-sticky {
position: fixed;
top: 50px;
- width: calc(100% - 346px);
+ width: calc(100% - 375px);
z-index: 40;
}
&.cw-ribbon-consume {
width: $consum_ribbon_width;
- position: fixed;
margin-bottom: 0;
}
.cw-ribbon-wrapper-left {
display: flex;
- max-width: calc(100% - 106px);
+ gap: 1em;
+ min-width: 0;
+ width: 100%;
+ flex: 1;
.cw-ribbon-nav {
display: flex;
- min-width: 75px;
+ align-items: center;
+
+ .contentbar-icon {
+ margin: 0 15px 0 10px;
- &.single-icon {
- min-width: 45px;
+ img {
+ vertical-align: middle;
+ }
}
}
@@ -138,11 +142,18 @@ $consum_ribbon_width: calc(100% - 58px);
}
}
}
+
+ .cw-ribbon-info-text {
+ font-size: small;
+ line-height: 1em;
+ }
}
}
.cw-ribbon-wrapper-right {
display: flex;
+ gap: 0.5em;
+ align-items: center;
button {
border: none;
@@ -174,7 +185,7 @@ $consum_ribbon_width: calc(100% - 58px);
&.cw-ribbon-button-next {
@include background-icon(arr_1right, clickable, 24);
- margin: 0 1em 0 0;
+ margin: 0 0.5em 0 0;
}
&.cw-ribbon-button-prev-disabled {
@@ -185,15 +196,13 @@ $consum_ribbon_width: calc(100% - 58px);
&.cw-ribbon-button-next-disabled {
@include background-icon(arr_1right, inactive, 24);
- margin: 0 1em 0 0;
+ margin: 0 0.5em 0 0;
cursor: default;
}
}
}
.cw-ribbon-action-menu {
- vertical-align: text-top;
- margin: 2px 0 0 2px;
&.is-open {
z-index: 32;
}
@@ -204,26 +213,25 @@ $consum_ribbon_width: calc(100% - 58px);
border: solid thin var(--content-color-40);
box-shadow: 2px 2px var(--dark-gray-color-30);
position: absolute;
- right: -570px;
- top: 15px;
+ right: 0;
+ top: 0;
height: 100%;
max-width: calc(100% - 28px);
display: flex;
flex-flow: row;
- transition: right 0.8s;
z-index: 42;
- &.unfold {
- right: 0px;
- margin-right: 15px;
- }
-
&.cw-ribbon-tools-consume {
- position: fixed;
+ right: 15px;
+ }
- &.unfold {
- right: 15px;
- }
+ &.cw-ribbon-slide-enter-active,
+ &.cw-ribbon-slide-leave-active {
+ transition: transform var(--transition-duration-superslow);
+ }
+ &.cw-ribbon-slide-enter,
+ &.cw-ribbon-slide-leave-to {
+ transform: translateX(calc(100% + 30px));
}
&.cw-ribbon-tools-sticky {
@@ -274,6 +282,7 @@ $consum_ribbon_width: calc(100% - 58px);
> .cw-tabs-nav {
border: none;
+ height: 57px;
width: calc(100% - 48px);
> button {
@@ -281,7 +290,7 @@ $consum_ribbon_width: calc(100% - 58px);
max-width: unset;
flex-grow: 0.5;
&::after {
- margin-top: 16px;
+ margin-top: 20px;
}
}
}
@@ -348,7 +357,7 @@ $consum_ribbon_width: calc(100% - 58px);
.cw-structural-element-consumemode {
.cw-ribbon-tools {
- top: 25px;
+ top: 18px;
}
}
@@ -370,3 +379,48 @@ $consum_ribbon_width: calc(100% - 58px);
height: 75px;
}
}
+
+// Rules extracted from PHP contentbar for use in ContentBar.vue
+.contentbar-button-wrapper {
+ height: 24px;
+ margin: 0 7px;
+
+ @-moz-document url-prefix() {
+ &.contentbar-toc-wrapper {
+ margin-top: -4px;
+ }
+ &.contentbar-action-menu-wrapper {
+ margin-top: 2px;
+ }
+ }
+
+ .contentbar-button,
+ .cw-ribbon-button {
+ background-color: transparent;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 24px;
+ border: none;
+ cursor: pointer;
+ display: inline-block;
+ height: 24px;
+ width: 24px;
+
+ &.contentbar-button-menu,
+ &.cw-ribbon-button-menu {
+ @include background-icon(table-of-contents, clickable, 24);
+ }
+
+ &.contentbar-button-zoom::before {
+ left: -5px;
+ position: relative;
+ top: -2px;
+ }
+
+ @-moz-document url-prefix() {
+ &.contentbar-button-zoom::before {
+ top: -3px;
+ }
+ }
+ }
+}
diff --git a/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss b/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss
index cf28364..023b31d 100644
--- a/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss
+++ b/resources/assets/stylesheets/scss/courseware/layouts/tabs.scss
@@ -24,7 +24,7 @@
&::after {
display: block;
margin-top: 4px;
- margin-bottom: -5px;
+ margin-bottom: -12px;
margin-left: -14px;
width: calc(100% + 28px);
content: '';
diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss
index c2ca5c3..0a84d3e 100644
--- a/resources/assets/stylesheets/scss/responsive.scss
+++ b/resources/assets/stylesheets/scss/responsive.scss
@@ -329,7 +329,7 @@ $sidebarOut: -330px;
&.responsive-show {
animation: slide-in var(--transition-duration) forwards;
position: sticky;
- top: 100px;
+ top: 110px;
visibility: visible;
}
@@ -383,10 +383,6 @@ $sidebarOut: -330px;
}
#responsive-contentbar {
- justify-content: stretch;
- margin-bottom: 15px;
- padding-bottom: 0.5em;
-
.contentbar-nav,
.cw-ribbon-nav {
.contentbar-button {
@@ -407,71 +403,20 @@ $sidebarOut: -330px;
}
}
- }
-
- .contentbar-wrapper-left {
- flex: 1;
- max-width: calc(100% - 70px);
- min-width: 0;
- width:100%;
-
- & > .contentbar-icon {
- margin-right: 15px;
- }
-
- .contentbar-breadcrumb {
- font-size: $font-size-large;
-
- > img {
- margin-left: 15px;
- width: 24px;
- }
-
- > span {
- display: inline;
- flex-shrink: 10000;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
- }
-
- > .contentbar-wrapper-right {
- flex: 0;
- left: 5px;
- position: relative;
-
- .contentbar-button,
- nav {
- position: relative;
+ img {
+ vertical-align: middle;
}
}
- &.cw-ribbon {
- .cw-ribbon-tools {
- max-width: calc(100% - 2px);
- top: 0;
- margin-right: 0;
- }
+ .cw-ribbon-tools {
+ right: 16px;
}
- &.cw-ribbon-sticky {
- position: unset;
- width: calc(100vw - 30px);
- }
}
#toc {
- max-width: 100vw;
- position: absolute;
- right: -8px;
- top: -21px;
- }
-
- #toc_header {
- height: 47px;
+ margin-right: -2px;
+ margin-top: -7px;
}
#main-footer {
@@ -509,7 +454,30 @@ $sidebarOut: -330px;
#responsive-contentbar {
&.cw-ribbon-sticky {
position: unset;
- width: calc(100vw - 30px);
+ width: unset;
+ }
+
+ .cw-ribbon-breadcrumb {
+ max-width: 100%;
+ min-width: unset;
+ }
+
+ .cw-ribbon-info-text,
+ .contentbar-icon {
+ display: none;
+ }
+
+ &:not(.cw-ribbon) {
+ .contentbar-wrapper-left {
+ flex: 1;
+ max-width: 100%;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .cw-ribbon-tools {
+ max-width: 100vw;
+ right: 0;
}
}
@@ -521,10 +489,9 @@ $sidebarOut: -330px;
height: calc(100% - 100px);
overflow-y: auto;
position: fixed;
- top: 75px;
transform: translateX($sidebarOut);
-webkit-transform: translateX($sidebarOut);
- top: 80px;
+ top: 110px;
z-index: 100;
&.responsive-show {
@@ -536,6 +503,12 @@ $sidebarOut: -330px;
}
}
+ #toc {
+ margin-right: -16px;
+ max-width: 100vw;
+ width: 100vw;
+ }
+
#system-notifications {
top: 0;
position: fixed;
@@ -619,13 +592,13 @@ $sidebarOut: -330px;
.contentbar-nav,
.cw-ribbon-nav {
- margin-left: -8px;
+ margin-left: -6px;
}
}
#content-wrapper {
flex: 1;
- margin-top: 75px;
+ margin-top: 55px;
min-height: calc(100vh - 150px);
}
}
@@ -686,11 +659,8 @@ $sidebarOut: -330px;
}
#toc {
- position: absolute;
- right: -29px;
- top: -25px;
+ margin-right: 12px;
}
-
}
html:not(.responsive-display):not(.fullscreen-mode) {
diff --git a/resources/assets/stylesheets/scss/table_of_contents.scss b/resources/assets/stylesheets/scss/table_of_contents.scss
index b361a90..e323c79 100644
--- a/resources/assets/stylesheets/scss/table_of_contents.scss
+++ b/resources/assets/stylesheets/scss/table_of_contents.scss
@@ -15,45 +15,57 @@ ul.numberedchapters {
display: none;
}
-#cb-toc:checked + .check-box + #cb-toc-close + article.toc_overview, button#toc-button:hover article.toc_overview {
- visibility: visible;
- width: 540px;
- overflow: hidden;
-}
-
-#cb-toc-close:checked article.toc_overview {
- visibility: hidden;
- width: 0;
-}
-
-.toc_overview {
- visibility: hidden;
- width: 0%;
+#toc {
+ margin: 11px;
+ text-align: left;
z-index: 100;
position: absolute;
- right: -22px;
- top: -25px;
+ right: -10px;
+ top: -11px;
background-color: var(--white);
border: 1px solid var(--content-color-40);
margin-bottom: 10px;
box-shadow: 2px 2px var(--dark-gray-color-30);
-
+ width: min(100%, 540px);
> section {
max-width: 100%;
overflow-y: scroll;
height: 580px;
margin-top: 7px;
+ padding: 5px 15px;
}
-}
-#toc {
- margin: 10px;
- text-align: left;
+ .toc-hide-button {
+ position: absolute;
+ border: none;
+ height: 36px;
+ width: 24px;
+ min-width: 24px;
+ margin-right: 1em;
+ padding: 0 4px;
+ right: 0;
+ top: 12px;
+ cursor: pointer;
+ @include background-icon(decline, clickable, 24);
+ background-repeat: no-repeat;
+ background-size: 24px;
+ background-position: center right;
+ background-color: var(--white);
+ }
+
+ &.cw-ribbon-slide-enter-active,
+ &.cw-ribbon-slide-leave-active {
+ transition: transform var(--transition-duration-superslow);
+ }
+ &.cw-ribbon-slide-enter,
+ &.cw-ribbon-slide-leave-to {
+ transform: translateX(calc(100% + 30px));
+ }
}
#toc_header {
- height: 58px;
+ height: 57px;
overflow: hidden;
background-color: var(--white);
color: var(--black);
@@ -73,14 +85,10 @@ ul.numberedchapters {
#toc_h1 {
color: var(--black);
font-weight: 500;
- margin-left: 10px;
+ margin-left: 25px;
margin-bottom: unset;
}
-.toc_transform {
- transition: all var(--transition-duration) ease;
-}
-
#main_content {
opacity: 1;
@@ -121,6 +129,13 @@ section > .toc {
img, svg {
vertical-align: bottom;
}
+
+ a,
+ span {
+ img {
+ margin-right: 10px;
+ }
+ }
}
li#chap1 {
@@ -196,10 +211,6 @@ section > .toc {
width:375px;
}
- #toc {
- max-width: 94%;
- }
-
ul.breadcrumb {
list-style: none;
font-size: 18px;
diff --git a/resources/studip.d.ts b/resources/studip.d.ts
index 5ec8fef..ec5c065 100644
--- a/resources/studip.d.ts
+++ b/resources/studip.d.ts
@@ -22,3 +22,24 @@ export interface InstalledLanguage {
picture: string;
selected: boolean;
}
+
+enum iconTypes {
+ 'accept',
+ 'attention',
+ 'clickable',
+ 'inactive',
+ 'info',
+ 'info_alt',
+ 'navigation',
+ 'new',
+ 'sort',
+ 'status-green',
+ 'status-red',
+ 'status-yellow'
+}
+
+export interface Icon {
+ role: iconTypes;
+ shape: string;
+ attributes: Record<string, unknown>;
+}
diff --git a/resources/vue/components/ContentBar.vue b/resources/vue/components/ContentBar.vue
new file mode 100644
index 0000000..539c582
--- /dev/null
+++ b/resources/vue/components/ContentBar.vue
@@ -0,0 +1,163 @@
+<template>
+ <div :class="{ 'cw-ribbon-wrapper-consume': consumeMode }"
+ :id="isContentBar ? 'contentbar' : undefined">
+ <div v-show="stickyRibbon"
+ class="cw-ribbon-sticky-top"></div>
+ <div class="cw-ribbon-header-container"
+ ref="headerContainer">
+ <header
+ ref="header"
+ :id="isContentBar ? 'cw-ribbon' : undefined"
+ class="cw-ribbon"
+ :class="{ 'cw-ribbon-sticky': stickyRibbon, 'cw-ribbon-consume': consumeMode }"
+ >
+ <div class="cw-ribbon-wrapper-left">
+ <nav class="cw-ribbon-nav contentbar-nav"
+ :class="buttonsClass">
+ <div class="contentbar-icon"
+ v-if="icon">
+ <a href="">
+ <studip-icon :shape="icon"
+ role="navigation"
+ :size="32" />
+ </a>
+ </div>
+ <slot name="buttons-left" />
+ </nav>
+ <nav class="cw-ribbon-breadcrumb">
+ <span v-if="title">
+ <a href="">
+ {{ title ?? $gettext('(Kein Titel)') }}
+ </a>
+ </span>
+ <slot v-if="breadcrumbFallback && $slots['breadcrumb-fallback']" name="breadcrumb-fallback" />
+ <slot v-else name="breadcrumb-list" />
+ <div class="cw-ribbon-info-text">
+ <slot name="info-text" />
+ </div>
+ </nav>
+ </div>
+ <div class="cw-ribbon-wrapper-right">
+ <slot name="buttons-right" />
+ <ContentBarTableOfContents v-if="toc" :toc="toc" />
+ <slot name="menu" />
+ </div>
+ <slot name="other" />
+ </header>
+ </div>
+ <div v-if="stickyRibbon" class="cw-ribbon-sticky-bottom"></div>
+ <div v-if="stickyRibbon" class="cw-ribbon-sticky-spacer"></div>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+import '../../assets/stylesheets/scss/courseware/layouts/ribbon.scss';
+import StudipIcon from './StudipIcon.vue';
+import { store } from '../../assets/javascripts/chunks/vue';
+import { TOCItem, traverse } from './table-of-contents';
+import ContentBarTableOfContents from './ContentBarTableOfContents.vue';
+
+export default defineComponent({
+ name: 'ContentBar',
+ components: { ContentBarTableOfContents, StudipIcon },
+ data() {
+ return {
+ stickyRibbon: false,
+ // This flag lets us run a hook the first time that this component's
+ // dom is updated by vue. See updated() hook.
+ hasPerformedFirstUpdate: false,
+ // This intersection observer allows us to respond to changes in
+ // the contentbar's visibility so that it is always displayed
+ // at the correct height on the page after being hidden or shown for
+ // any reason (e.g. when Courseware search results are opened/closed).
+ observer: undefined as IntersectionObserver | undefined,
+ };
+ },
+ props: {
+ // (Optional) A class that is applied to the <nav> element where the
+ // 'icon', if any (see 'icon' prop), and the buttons-left slot are displayed.
+ buttonsClass: String,
+ // (Optional) If provided, displayed in the same place as the breadcrumb-list
+ // and breadcrumb-fallback slots.
+ title: String,
+ // (Optional) If provided, displays the given icon before the 'icons-left' slot.
+ icon: String,
+ // If true, this element serves as the global contentbar.
+ // It will stick to the top of the screen when the page is scrolled down,
+ // and it will be pinned to the top of the screen in compact mode.
+ // If false, this element will not be used as the global contentbar.
+ // It will just be a normal element on the page that looks the same as the
+ // global contentbar, but does not have any special sticky behavior.
+ isContentBar: {
+ type: Boolean,
+ required: false,
+ },
+ // (Optional) If provided, a 'table of contents' icon will be shown on
+ // the right side of the ContentBar. When clicked, it will open/close a
+ // panel with the table of contents inside.
+ toc: Object as PropType<TOCItem>,
+ },
+ mounted() {
+ window.addEventListener('scroll', this.handleScroll);
+ this.observer = new IntersectionObserver(this.intersectionCallback);
+ this.observer.observe(this.$el);
+ this.$forceUpdate();
+ },
+ updated() {
+ // The "Responsive Toolbar" works by reaching inside the DOM template of
+ // this component and grabbing some elements from it to stick them
+ // in the ResponsiveToolbar, jquery-style.
+ // That only works if the elements are actually present in the DOM
+ // when the courseware-contentbar-mounted event is fired.
+ // To ensure that that is the case, we defer emitting that event until
+ // this component's dom has been fully rendered for the first time.
+ // This trick is brought to you by the Vue 2 docs:
+ // https://v2.vuejs.org/v2/api/#updated
+ if (this.hasPerformedFirstUpdate) {
+ return;
+ }
+ this.hasPerformedFirstUpdate = true;
+ this.$nextTick(() => {
+ if (this.isContentBar) {
+ // TODO rename this event.
+ window.STUDIP.eventBus.emit('courseware-contentbar-mounted', this);
+ }
+ });
+ },
+ beforeDestroy() {
+ if (this.isContentBar) {
+ window.STUDIP.eventBus.emit('courseware-contentbar-before-destroy', this);
+ }
+ window.removeEventListener('scroll', this.handleScroll);
+ this.observer!.disconnect();
+ },
+ watch: {
+ stickyRibbon(value) {
+ this.$emit('stickyRibbonChange', value);
+ },
+ },
+ computed: {
+ consumeMode(): boolean {
+ // We have to access the global studipStore over an import rather than
+ // using $store/mapState/mapGetters/etc., because this component is
+ // compatible with Courseware, and in the various Courseware apps,
+ // $store does not include the global StudipStore.
+ return store.state.studip.consumeMode;
+ },
+ breadcrumbFallback(): boolean {
+ return window.outerWidth < 1200;
+ },
+ },
+ methods: {
+ intersectionCallback(entries: IntersectionObserverEntry[]) {
+ this.handleScroll();
+ },
+ handleScroll() {
+ const top = this.$el.getBoundingClientRect().top;
+ this.stickyRibbon = this.isContentBar && top <= 50 && !this.consumeMode;
+ },
+ },
+});
+</script>
diff --git a/resources/vue/components/ContentBarBreadcrumbs.vue b/resources/vue/components/ContentBarBreadcrumbs.vue
new file mode 100644
index 0000000..35606b4
--- /dev/null
+++ b/resources/vue/components/ContentBarBreadcrumbs.vue
@@ -0,0 +1,78 @@
+<template>
+ <ul>
+ <li v-for="(breadcrumb, index) in breadcrumbs" class="cw-ribbon-breadcrumb-item" :key="index">
+ <span v-if="breadcrumb.active">{{ breadcrumb.title }}</span>
+ <a v-else :href="breadcrumb.url">{{ breadcrumb.title }}</a>
+ </li>
+ </ul>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import { TOCItem, traverse } from './table-of-contents';
+
+interface Breadcrumb {
+ title: string;
+ url: string;
+ active: boolean;
+}
+
+export default defineComponent({
+ name: 'ContentBarBreadcrumbs',
+ props: {
+ // The table of contents tree for the page that is currently open.
+ toc: {
+ type: Object as PropType<TOCItem>,
+ required: true,
+ },
+ },
+ computed: {
+ // Convert the nested TOCItem into a list of breadcrumbs we can iterate through
+ // in the template.
+ breadcrumbs(): Breadcrumb[] {
+ // First, clone the toc and add parent references to it.
+ // (The parent references are lost in serialization from PHP to JS.)
+ const tocClone = JSON.parse(JSON.stringify(this.toc));
+ this.addParentReferences(tocClone);
+
+ // Then, find the TOCItem corresponding to the page that is currently open.
+ const activeTocItem = this.findActiveTocItem(tocClone);
+ if (!activeTocItem) {
+ console.error('No TOCItem is marked as active. No breadcrumbs will be rendered.');
+ return [];
+ }
+
+ // Finally, iterate upwards from the active TOC Item, through its parent, grandparent, ...
+ // up to the root, generating a breadcrumb at each step of the way.
+ const breadcrumbs = [{ title: activeTocItem.title, url: activeTocItem.url, active: true }];
+ let current = activeTocItem;
+ while (current.parent) {
+ current = current.parent;
+ breadcrumbs.push({ title: current.title, url: current.url, active: false });
+ }
+ return breadcrumbs.reverse();
+ },
+ },
+ methods: {
+ // Find the TOCItem, if any, that is marked as active in the given toc tree.
+ findActiveTocItem(toc: TOCItem): TOCItem | undefined {
+ let activeItem: TOCItem | undefined;
+ traverse(toc, (item) => {
+ if (item.active) {
+ activeItem = item;
+ }
+ });
+ return activeItem;
+ },
+ // Augment each node in the given toc tree with a reference to its parent.
+ addParentReferences(tocItem: TOCItem, parent?: TOCItem): void {
+ if (parent) {
+ tocItem.parent = parent;
+ }
+ for (let child of tocItem.children) {
+ this.addParentReferences(child, tocItem);
+ }
+ },
+ },
+});
+</script>
diff --git a/resources/vue/components/ContentBarTableOfContents.vue b/resources/vue/components/ContentBarTableOfContents.vue
new file mode 100644
index 0000000..525c4d6
--- /dev/null
+++ b/resources/vue/components/ContentBarTableOfContents.vue
@@ -0,0 +1,63 @@
+<template>
+ <div class="contentbar-button-wrapper contentbar-toc-wrapper">
+ <button v-if="!tocOpen"
+ class="cw-ribbon-button cw-ribbon-button-menu"
+ :title="$gettext('Inhaltsverzeichnis öffnen')"
+ @click.prevent="showTOC(true)"></button>
+ <transition name="cw-ribbon-slide" appear>
+ <article v-if="tocOpen" id="toc">
+ <header id="toc_header">
+ <h1 id="toc_h1">
+ {{ $gettextInterpolate('Inhalt (%{count} Elemente)', { count: tocItemsCount }) }}
+ </h1>
+ <button class="toc-hide-button"
+ :title="$gettext('Inhaltsverzeichnis schließen')"
+ @click.prevent="showTOC(false)"></button>
+ </header>
+ <section>
+ <ul class="toc">
+ <ContentBarTocItemList :toc="toc" />
+ </ul>
+ </section>
+ </article>
+ </transition>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import { TOCItem, traverse } from './table-of-contents';
+import ContentBarTocItemList from './ContentBarTocItemList.vue';
+
+export default defineComponent({
+ name: 'ContentBarTableOfContents',
+ components: { ContentBarTocItemList },
+ props: {
+ toc: {
+ required: true,
+ type: Object as PropType<TOCItem>,
+ },
+ },
+ data() {
+ return {
+ tocOpen: false
+ }
+ },
+ computed: {
+ tocItemsCount(): number {
+ if (!this.toc) {
+ return 0;
+ }
+ // Count how many items are in the TOC tree.
+ let count = 0;
+ traverse(this.toc, (item) => count++);
+ return count;
+ },
+ },
+ methods: {
+ showTOC(state = true) {
+ this.tocOpen = state;
+ }
+ }
+});
+</script>
diff --git a/resources/vue/components/ContentBarTocItemList.vue b/resources/vue/components/ContentBarTocItemList.vue
new file mode 100644
index 0000000..15101c4
--- /dev/null
+++ b/resources/vue/components/ContentBarTocItemList.vue
@@ -0,0 +1,33 @@
+<template>
+ <li class="chapter" :class="{ active: toc.active }">
+ <span v-if="toc.active">
+ <StudipIcon v-if="toc.icon" :shape="toc.icon.shape" role="info" :size="24" />
+ {{ toc.title }}
+ </span>
+ <a v-else class="navigate" :href="toc.url">
+ <StudipIcon v-if="toc.icon" :shape="toc.icon.shape" role="info" :size="24" />
+ {{ toc.title }}
+ </a>
+ <ul v-if="toc.children.length > 0">
+ <ContentBarTocItemList v-for="child in toc.children" :key="child.url" :toc="child" />
+ </ul>
+ </li>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+import { TOCItem } from './table-of-contents';
+import StudipIcon from './StudipIcon.vue';
+
+export default defineComponent({
+ name: 'ContentBarTocItemList',
+ components: { StudipIcon },
+ props: {
+ toc: {
+ required: true,
+ type: Object as PropType<TOCItem>,
+ },
+ },
+});
+</script>
diff --git a/resources/vue/components/WikiEditor.vue b/resources/vue/components/WikiEditor.vue
index 069b2ff..3b15bb0 100644
--- a/resources/vue/components/WikiEditor.vue
+++ b/resources/vue/components/WikiEditor.vue
@@ -1,5 +1,14 @@
<template>
<div>
+ <ContentBar isContentBar icon="wiki" :toc="toc">
+ <template #info-text>
+ {{ $gettext('Zuletzt gespeichert') }}:
+ <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)"
+ :relative="true"
+ />
+ </template>
+ <template #breadcrumb-list><content-bar-breadcrumbs :toc="toc"/></template>
+ </ContentBar>
<form :action="saveUrl" method="post" class="default" v-show="isEditing">
<input type="hidden" :name="csrf.name" :value="csrf.value">
@@ -67,21 +76,18 @@
<wiki-editor-online-users :users="onlineUsers"></wiki-editor-online-users>
- <mounting-portal :mount-to="`.wiki-last-edited-${pageId}`">
- <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)"
- :relative="true"
- ></studip-date-time>
- </mounting-portal>
</div>
</template>
<script>
import WikiEditorOnlineUsers from "./WikiEditorOnlineUsers.vue";
import StudipDateTime from "./StudipDateTime.vue";
import JSUpdater from "@/assets/javascripts/lib/jsupdater";
+import ContentBar from "./ContentBar.vue";
+import ContentBarBreadcrumbs from "./ContentBarBreadcrumbs.vue";
export default {
name: 'wiki-editor',
- components: {StudipDateTime, WikiEditorOnlineUsers },
+ components: { ContentBarBreadcrumbs, ContentBar, StudipDateTime, WikiEditorOnlineUsers },
props: {
cancelUrl: {
type: String,
@@ -114,6 +120,10 @@ export default {
users: {
type: Array,
default: () => []
+ },
+ toc: {
+ type: Object,
+ required: true
}
},
data() {
diff --git a/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue b/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue
index f7cfff0..3f6eaba 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareRibbon.vue
@@ -1,168 +1,99 @@
<template>
- <div :class="{ 'cw-ribbon-wrapper-consume': consumeMode }" :id="isContentBar ? 'contentbar' : null" >
- <div v-show="stickyRibbon" class="cw-ribbon-sticky-top"></div>
- <header :id="isContentBar ? 'cw-ribbon' : null" class="cw-ribbon" :class="{ 'cw-ribbon-sticky': stickyRibbon, 'cw-ribbon-consume': consumeMode }">
- <div class="cw-ribbon-wrapper-left">
- <nav class="cw-ribbon-nav" :class="buttonsClass">
- <slot name="buttons" />
- </nav>
- <nav class="cw-ribbon-breadcrumb">
- <ul>
- <slot v-if="breadcrumbFallback" name="breadcrumbFallback" />
- <slot v-else name="breadcrumbList" />
- </ul>
- </nav>
- </div>
- <div class="cw-ribbon-wrapper-right">
- <button
- v-if="showToolbarButton"
- class="cw-ribbon-button cw-ribbon-button-menu"
- :title="textRibbon.toolbar"
- @click.prevent="activeToolbar"
- >
- </button>
- <slot name="menu" />
- </div>
- <div v-if="consumeMode" class="cw-ribbon-consume-bottom"></div>
- <courseware-ribbon-toolbar
- v-if="showTools"
- :toolsActive="unfold"
- :stickyRibbon="stickyRibbon"
- :class="{ 'cw-ribbon-tools-sticky': stickyRibbon }"
- :style="{ height: toolbarHeight + 'px' }"
- :canEdit="canEdit"
- @deactivate="deactivateToolbar"
- @blockAdded="$emit('blockAdded')"
- />
- </header>
- <div v-if="stickyRibbon" class="cw-ribbon-sticky-bottom"></div>
- <div v-if="stickyRibbon" class="cw-ribbon-sticky-spacer"></div>
- </div>
+ <content-bar is-content-bar @stickyRibbonChange="onStickyRibbonChange">
+ <template #buttons-right>
+ <button
+ class="cw-ribbon-button cw-ribbon-button-menu"
+ :title="strings.toolbar"
+ @click.prevent="activateToolbar"
+ ></button>
+ </template>
+ <template #other>
+ <transition name="cw-ribbon-slide">
+ <courseware-ribbon-toolbar
+ ref="toolbar"
+ v-show="showToolbar"
+ :stickyRibbon="stickyRibbon"
+ :class="{ 'cw-ribbon-tools-sticky': stickyRibbon }"
+ :style="{ height: toolbarHeight + 'px' }"
+ @deactivate="deactivateToolbar"
+ @blockAdded="$emit('blockAdded')"
+ />
+ </transition>
+ </template>
+ <!-- Pass these slots through to the ContentBar. -->
+ <template #menu><slot name="menu" /></template>
+ <template #buttons-left><slot name="buttons-left" /></template>
+ <template #breadcrumb-list><slot name="breadcrumb-list" /></template>
+ <template #breadcrumb-fallback><slot name="breadcrumb-fallback" /></template>
+ <template #info-text><slot name="info-text"/></template>
+ </content-bar>
</template>
-<script>
+<script lang="ts">
+import ContentBar from '../../ContentBar.vue';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import Vue from 'vue';
import CoursewareRibbonToolbar from './CoursewareRibbonToolbar.vue';
-import { mapActions, mapGetters } from 'vuex';
+import { store } from '../../../../assets/javascripts/chunks/vue';
-export default {
- name: 'courseware-ribbon',
- components: {
- CoursewareRibbonToolbar,
- },
- props: {
- canEdit: Boolean,
- showToolbarButton: {
- default: true,
- type: Boolean
- },
- showModeSwitchButton: {
- default: true,
- type: Boolean
- },
- buttonsClass: String,
- isContentBar: {
- type: Boolean,
- default: false
- }
- },
+export default Vue.extend({
+ name: 'CoursewareRibbon',
+ components: { CoursewareRibbonToolbar, ContentBar },
data() {
return {
- readModeActive: false,
+ // This value is derived from stickyRibbonChange events emitted by
+ // the ContentBar component (see template).
stickyRibbon: false,
- textRibbon: {
- toolbar: this.$gettext('Inhaltsverzeichnis'),
- fullscreen_on: this.$gettext('Fokusmodus einschalten'),
- fullscreen_off: this.$gettext('Fokusmodus ausschalten'),
- },
- unfold: false,
- showTools: false,
};
},
computed: {
...mapGetters({
- consumeMode: 'consumeMode',
- toolsActive: 'showToolbar'
+ showToolbar: 'showToolbar',
}),
- breadcrumbFallback() {
- return window.outerWidth < 1200;
+ consumeMode(): boolean {
+ // TODO ensure that there is only one global StudipStore / 'studip' store module
+ // across Courseware and chunks/vue.js.
+ // Currently, the 'studip' module of the courseware store is deceivingly named.
+ // It is a completely different store than the one in chunks/vue.js.
+ // It just happens to have a module with the same name, 'studip'.
+ // So, to access the global studipStore, we have to import it and access it like this.
+ return store.state.studip.consumeMode;
+ },
+ strings() {
+ return {
+ toolbar: this.$gettext('Inhaltsverzeichnis'),
+ };
},
toolbarHeight() {
if (this.stickyRibbon) {
- return parseInt(window.innerHeight * 0.75);
+ return window.innerHeight * 0.75;
} else {
- return parseInt(Math.min(window.innerHeight * 0.75, window.innerHeight - 197));
+ return Math.min(window.innerHeight * 0.75, window.innerHeight - 197);
}
- }
+ },
+ },
+ watch: {
+ consumeMode(newState: boolean) {
+ if (newState) {
+ console.log('consumeMode watcher ', newState, 'setting coursewareViewMode "read"');
+ this.coursewareViewMode('read');
+ }
+ },
},
methods: {
+ onStickyRibbonChange(value: boolean) {
+ this.stickyRibbon = value;
+ },
...mapActions({
- coursewareConsumeMode: 'coursewareConsumeMode',
coursewareViewMode: 'coursewareViewMode',
- coursewareShowToolbar: 'coursewareShowToolbar'
-
+ coursewareShowToolbar: 'coursewareShowToolbar',
}),
- toggleConsumeMode() {
- STUDIP.eventBus.emit('toggle-focus-mode', !this.consumeMode);
- if (!this.consumeMode) {
- document.body.classList.add('consuming_mode');
- this.coursewareConsumeMode(true);
- this.coursewareViewMode('read');
- } else {
- this.coursewareConsumeMode(false);
- document.body.classList.remove('consuming_mode');
- }
- },
- activeToolbar() {
+ activateToolbar() {
this.coursewareShowToolbar(true);
},
deactivateToolbar() {
this.coursewareShowToolbar(false);
},
- handleScroll() {
- if (window.outerWidth > 767) {
- this.stickyRibbon = window.scrollY > 128 && !this.consumeMode;
- } else {
- this.stickyRibbon = window.scrollY > 75 && !this.consumeMode;
- }
- },
- },
- mounted() {
- window.addEventListener('scroll', this.handleScroll);
- if (this.isContentBar) {
- STUDIP.eventBus.emit('courseware-contentbar-mounted', this);
- }
-
- this.globalOn('switch-focus-mode', (state) => {
- if (state !== this.consumeMode) {
- this.toggleConsumeMode();
- }
- });
},
- beforeDestroy() {
- STUDIP.eventBus.off('switch-focus-mode');
- },
- watch: {
- toolsActive(newState, oldState) {
- let view = this;
- if(newState) {
- this.showTools = true;
- setTimeout(() => {view.unfold = true}, 10);
- } else {
- this.unfold = false;
- setTimeout(() => {
- if(!view.toolsActive) {
- view.showTools = false;
- }
- }, 800);
- }
- },
- consumeMode(newState) {
- if (newState) {
- document.body.classList.add('consuming_mode');
- } else {
- document.body.classList.remove('consuming_mode');
- }
- }
- }
-};
+});
</script>
diff --git a/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue b/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue
index 2a92579..fe99808 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareRibbonToolbar.vue
@@ -2,7 +2,7 @@
<focus-trap v-model="trap" :initial-focus="() => initialFocusElement" :clickOutsideDeactivates="true" :fallbackFocus ="() => fallbackFocusElement">
<div
class="cw-ribbon-tools"
- :class="{ unfold: toolsActive, 'cw-ribbon-tools-consume': consumeMode }"
+ :class="{ 'cw-ribbon-tools-consume': consumeMode }"
>
<div class="cw-ribbon-tool-content">
<div class="cw-ribbon-tool-content-nav">
@@ -49,6 +49,7 @@ import CoursewareToolsContents from './CoursewareToolsContents.vue';
import CoursewareToolsUnits from './CoursewareToolsUnits.vue';
import { FocusTrap } from 'focus-trap-vue';
import { mapActions, mapGetters } from 'vuex';
+import { store } from "../../../../assets/javascripts/chunks/vue";
export default {
name: 'courseware-ribbon-toolbar',
@@ -60,16 +61,6 @@ export default {
FocusTrap,
},
props: {
- toolsActive: Boolean,
- canEdit: Boolean,
- disableSettings: {
- type: Boolean,
- default: false,
- },
- disableAdder: {
- type: Boolean,
- default: false,
- },
stickyRibbon: {
type: Boolean,
default: false,
@@ -84,9 +75,11 @@ export default {
};
},
computed: {
+ consumeMode() {
+ return store.state.studip.consumeMode;
+ },
...mapGetters({
userIsTeacher: 'userIsTeacher',
- consumeMode: 'consumeMode',
containerAdder: 'containerAdder',
adderStorage: 'blockAdder',
viewMode: 'viewMode',
@@ -108,28 +101,25 @@ export default {
coursewareContainerAdder: 'coursewareContainerAdder',
}),
scrollToCurrent() {
- setTimeout(() => {
- let contents = this.$refs.contents.$el;
- let current = contents.querySelector('.cw-tree-item-link-current');
- if (current) {
- contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' });
- }
- }, 360);
+ let contents = this.$refs.contents.$el;
+ let current = contents.querySelector('.cw-tree-item-link-current');
+ if (current) {
+ contents.scroll({ top: current.offsetTop - 4, behavior: 'smooth' });
+ }
},
- },
- mounted () {
- this.scrollToCurrent();
- },
- watch: {
- toolsActive(newValue) {
+ activate() {
const focusElement = this.$refs.tabs.getTabButtonByAlias(this.selectedToolbarItem);
- if (newValue && focusElement) {
- setTimeout(() => {
- this.initialFocusElement = focusElement;
- this.trap = true;
- }, 300);
+ if (focusElement) {
+ this.initialFocusElement = focusElement;
+ this.trap = true;
}
},
},
+ mounted() {
+ this.$nextTick(() => {
+ this.activate();
+ this.$nextTick(() => this.scrollToCurrent());
+ });
+ },
};
</script>
diff --git a/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue b/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue
index 6efbca2..5d7547d 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareSearchResults.vue
@@ -1,14 +1,10 @@
<template>
<div role="region" id="search" aria-live="polite">
- <courseware-ribbon
- :showToolbarButton="false"
- :showModeSwitchButton="false"
- buttonsClass="single-icon"
- >
- <template #buttons>
+ <ContentBar>
+ <template #buttons-left>
<studip-icon shape="search" :size="24" />
</template>
- <template #breadcrumbList>
+ <template #breadcrumb-list>
<translate>Suchergebnisse</translate>
</template>
<template #menu>
@@ -16,7 +12,7 @@
<studip-icon shape="decline" :size="24"/>
</button>
</template>
- </courseware-ribbon>
+ </ContentBar>
<div id="search-results">
<article v-if="searchResults.length > 0">
<section v-for="result in searchResults" :key="result['structural-element-id']">
@@ -49,15 +45,15 @@
</template>
<script>
-import CoursewareRibbon from './CoursewareRibbon.vue';
import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue';
import StudipIcon from '../../StudipIcon.vue';
import { mapActions, mapGetters } from 'vuex';
+import ContentBar from "../../ContentBar.vue";
export default {
name: 'courseware-search-results',
components: {
- CoursewareRibbon,
+ ContentBar,
CoursewareCompanionBox,
StudipIcon
},
diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
index 2b7cdbc..271af3e 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue
@@ -8,11 +8,8 @@
>
<div v-if="structuralElement" class="cw-structural-element-content">
<courseware-ribbon
- :canEdit="canEdit && canAddElements"
- :isContentBar="true"
- @blockAdded="updateContainerList"
- >
- <template #buttons>
+ @blockAdded="updateContainerList">
+ <template #buttons-left>
<router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id">
<div class="cw-ribbon-button cw-ribbon-button-prev" :title="$gettext('zurück')" />
</router-link>
@@ -30,66 +27,68 @@
:title="$gettext('Keine nächste Seite')"
/>
</template>
- <template #breadcrumbList>
- <li
- v-for="ancestor in ancestors"
- :key="ancestor.id"
- :title="ancestor.attributes.title"
- class="cw-ribbon-breadcrumb-item"
- >
- <span>
- <router-link :to="'/structural_element/' + ancestor.id">{{
- ancestor.attributes.title || '–'
- }}</router-link>
- </span>
- </li>
- <li
- class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
- :title="structuralElement.attributes.title"
- >
- <span>{{ structuralElement.attributes.title || '–' }}</span>
- <span v-if="isTask">[ {{ solverName }} ]</span>
- <template v-if="!userIsTeacher && inCourse">
- <studip-icon
- v-if="complete"
- shape="accept"
- role="info"
- :title="$gettext('Diese Seite wurde von Ihnen vollständig bearbeitet')"
- />
- <span
- v-else
+ <template #breadcrumb-list>
+ <ul>
+ <li
+ v-for="ancestor in ancestors"
+ :key="ancestor.id"
+ :title="ancestor.attributes.title"
+ class="cw-ribbon-breadcrumb-item"
+ >
+ <span>
+ <router-link :to="'/structural_element/' + ancestor.id">{{ ancestor.attributes.title || '–' }}</router-link>
+ </span>
+ </li>
+ <li
+ class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
+ :title="structuralElement.attributes.title"
+ >
+ <span>{{ structuralElement.attributes.title || '–' }}</span>
+ <span v-if="isTask">[ {{ solverName }} ]</span>
+ <template v-if="!userIsTeacher && inCourse">
+ <studip-icon
+ v-if="complete"
+ shape="accept"
+ role="info"
+ :title="$gettext('Diese Seite wurde von Ihnen vollständig bearbeitet')"
+ />
+ <span
+ v-else
+ :title="$gettextInterpolate(
+ $gettext('Fortschritt: %{progress} %'),{
+ progress: elementProgress,
+ })
+ "
+ >
+ ({{ elementProgress }} %)
+ </span>
+ </template>
+ <studip-five-stars
+ v-if="showFeedbackInContentbar && hasFeedbackElement"
+ :amount="hasFeedbackAverage ? feedbackAverage : 5"
+ :size="16"
+ :role="hasFeedbackAverage ? 'status-yellow' : 'inactive'"
:title="
- $gettextInterpolate($gettext('Fortschritt: %{progress} %'), {
- progress: elementProgress,
+ hasFeedbackAverage
+ ?$gettextInterpolate($gettext('Seite wurde mit %{avg} Sternen bewertet'), {
+ avg: feedbackAverage,
})
+ :$gettext('Seite wurde noch nicht bewertet')
"
- >
- ({{ elementProgress }} %)
- </span>
- </template>
- <studip-five-stars
- v-if="showFeedbackInContentbar && hasFeedbackElement"
- :amount="hasFeedbackAverage ? feedbackAverage : 5"
- :size="16"
- :role="hasFeedbackAverage ? 'status-yellow' : 'inactive'"
- :title="
- hasFeedbackAverage
- ? $gettextInterpolate($gettext('Seite wurde mit %{avg} Sternen bewertet'), {
- avg: feedbackAverage,
- })
- : $gettext('Seite wurde noch nicht bewertet')
- "
- @click="menuAction('showFeedback')"
- />
- </li>
+ @click="menuAction('showFeedback')"
+ />
+ </li>
+ </ul>
</template>
- <template #breadcrumbFallback>
- <li
- class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
- :title="structuralElement.attributes.title"
- >
- <span>{{ structuralElement.attributes.title }}</span>
- </li>
+ <template #breadcrumb-fallback>
+ <ul>
+ <li
+ class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
+ :title="structuralElement.attributes.title"
+ >
+ <span>{{ structuralElement.attributes.title }}</span>
+ </li>
+ </ul>
</template>
<template #menu>
<studip-action-menu
@@ -440,6 +439,7 @@ import CoursewareStructuralElementDialogPublicLink from './CoursewareStructuralE
import CoursewareStructuralElementDiscussion from './CoursewareStructuralElementDiscussion.vue';
import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue';
+import CoursewareRibbon from "./CoursewareRibbon.vue";
import CoursewareExport from '@/vue/mixins/courseware/export.js';
import colorMixin from '@/vue/mixins/courseware/colors.js';
@@ -455,6 +455,7 @@ import StudipProgressIndicator from '../../StudipProgressIndicator.vue';
import draggable from 'vuedraggable';
import containerMixin from '@/vue/mixins/courseware/container.js';
import { mapActions, mapGetters } from 'vuex';
+import { store } from "../../../../assets/javascripts/chunks/vue";
export default {
name: 'courseware-structural-element',
@@ -488,6 +489,7 @@ export default {
StudipDialog,
StudipProgressIndicator,
draggable,
+ CoursewareRibbon,
}),
props: ['canVisit', 'orderedStructuralElements', 'structuralElement'],
@@ -526,12 +528,14 @@ export default {
},
computed: {
+ consumeMode() {
+ return store.state.studip.consumeMode;
+ },
...mapGetters({
courseware: 'courseware',
rootId: 'rootId',
currentUnit: 'currentUnit',
context: 'context',
- consumeMode: 'consumeMode',
containerById: 'courseware-containers/byId',
relatedContainers: 'courseware-containers/related',
relatedStructuralElements: 'courseware-structural-elements/related',
diff --git a/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue b/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue
index e968f7c..8f0ec3b 100644
--- a/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue
+++ b/resources/vue/components/courseware/structural-element/CoursewareWelcomeScreen.vue
@@ -25,7 +25,6 @@ export default {
name: 'courseware-welcome-screen',
computed: {
...mapGetters({
- consumeMode: 'consumeMode',
lastCreatedBlocks: 'courseware-blocks/lastCreated',
lastCreatedContainers: 'courseware-containers/lastCreated'
}),
@@ -40,13 +39,11 @@ export default {
lockObject: 'lockObject',
unlockObject: 'unlockObject',
- coursewareConsumeMode: 'coursewareConsumeMode',
coursewareContainerAdder: 'coursewareContainerAdder',
coursewareShowToolbar: 'coursewareShowToolbar'
}),
addContainer() {
- this.coursewareConsumeMode(false);
this.coursewareShowToolbar(true);
this.$nextTick(() => {
this.coursewareContainerAdder(true);
@@ -67,7 +64,6 @@ export default {
section: 0,
blockType: 'text',
});
- this.coursewareConsumeMode(false);
this.companionSuccess({
info: this.$gettext('Das Elemente für Ihren ersten Inhalt wurde angelegt.'),
});
@@ -76,7 +72,7 @@ export default {
const structuralElementId = this.$route.params.id
await this.updateContainer({ container: newContainer, structuralElementId: structuralElementId });
await this.unlockObject({ id: newContainer.id, type: 'courseware-containers' });
-
+
}
}
diff --git a/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue
index 5651171..a63d1f8 100644
--- a/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/structural-element/PublicCoursewareStructuralElement.vue
@@ -5,8 +5,8 @@
class="cw-structural-element"
>
<div v-if="structuralElement" class="cw-structural-element-content">
- <courseware-ribbon :canEdit="false" :disableSettings="true" :disableAdder="true">
- <template #buttons>
+ <ContentBar isContentBar>
+ <template #buttons-left>
<router-link v-if="prevElement" :to="'/structural_element/' + prevElement.id">
<div class="cw-ribbon-button cw-ribbon-button-prev" :title="textRibbon.perv" />
</router-link>
@@ -16,7 +16,7 @@
</router-link>
<div v-else class="cw-ribbon-button cw-ribbon-button-next-disabled" :title="$gettext('keine nächste Seite')"/>
</template>
- <template #breadcrumbList>
+ <template #breadcrumb-list>
<li
v-for="ancestor in ancestors"
:key="ancestor.id"
@@ -34,7 +34,7 @@
<span>{{ structuralElement.attributes.title || "–" }}</span>
</li>
</template>
- <template #breadcrumbFallback>
+ <template #breadcrumb-fallback>
<li
class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current"
:title="structuralElement.attributes.title"
@@ -42,7 +42,7 @@
<span>{{ structuralElement.attributes.title }}</span>
</li>
</template>
- </courseware-ribbon>
+ </ContentBar>
<div
class="cw-container-wrapper"
@@ -79,12 +79,15 @@ import CoursewarePluginComponents from '../plugin-components.js';
import CoursewareCompanionOverlay from '../layouts/CoursewareCompanionOverlay.vue';
import { mapActions, mapGetters } from 'vuex';
+import ContentBar from "../../ContentBar.vue";
+import { store } from "../../../../assets/javascripts/chunks/vue";
export default {
name: 'public-courseware-structural-element',
components: Object.assign(StructuralElementComponents, {
CoursewareCompanionOverlay,
+ ContentBar,
}),
props: ['orderedStructuralElements', 'structuralElement'],
@@ -100,10 +103,12 @@ export default {
},
computed: {
+ consumeMode() {
+ return store.state.studip.consumeMode;
+ },
...mapGetters({
courseware: 'courseware',
context: 'context',
- consumeMode: 'consumeMode',
containerById: 'courseware-containers/byId',
pluginManager: 'pluginManager',
relatedContainers: 'courseware-containers/related',
diff --git a/resources/vue/components/courseware/structural-element/structural-element-components.js b/resources/vue/components/courseware/structural-element/structural-element-components.js
index 40586c2..0627eb9 100644
--- a/resources/vue/components/courseware/structural-element/structural-element-components.js
+++ b/resources/vue/components/courseware/structural-element/structural-element-components.js
@@ -1,6 +1,6 @@
import CoursewareToolbar from './../toolbar/CoursewareToolbar.vue';
// contentbar
-import CoursewareRibbon from './CoursewareRibbon.vue';
+import ContentBar from "../../ContentBar.vue";
import CoursewareTabs from '../layouts/CoursewareTabs.vue';
import CoursewareTab from '../layouts/CoursewareTab.vue';
import { FocusTrap } from 'focus-trap-vue';
@@ -16,7 +16,7 @@ import CoursewareTabsContainer from '../containers/CoursewareTabsContainer.vue';
const StructuralElementComponents = {
CoursewareToolbar,
//contentbar
- CoursewareRibbon,
+ ContentBar,
CoursewareTabs,
CoursewareTab,
FocusTrap,
@@ -30,4 +30,4 @@ const StructuralElementComponents = {
CoursewareTabsContainer
}
-export default StructuralElementComponents; \ No newline at end of file
+export default StructuralElementComponents;
diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
index 426b0cb..2b86c71 100644
--- a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
+++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue
@@ -1,17 +1,17 @@
<template>
<div class="cw-dashboard-students-wrapper">
- <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
- <template #buttons>
+ <ContentBar isContentBar>
+ <template #buttons-left>
<router-link :to="{ name: 'task-groups-index' }">
<StudipIcon shape="category-task" :size="24" />
</router-link>
</template>
- <template #breadcrumbList>
+ <template #breadcrumb-list>
<li>
{{ $gettext('Aufgaben') }}
</li>
</template>
- </CoursewareRibbon>
+ </ContentBar>
<table class="default" v-if="taskGroups.length">
<thead>
<tr class="sortable">
@@ -79,7 +79,6 @@
import _ from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
-import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue';
import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
import StudipActionMenu from '../../StudipActionMenu.vue';
import StudipDate from '../../StudipDate.vue';
@@ -88,12 +87,13 @@ import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue';
import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue';
import { getStatus } from './task-groups-helper.js';
+import ContentBar from "../../ContentBar.vue";
export default {
name: 'courseware-dashboard-students',
components: {
+ ContentBar,
CompanionBox,
- CoursewareRibbon,
CoursewareTasksDialogDistribute,
StudipActionMenu,
StudipDate,
@@ -207,12 +207,6 @@ export default {
</script>
<style scoped>
-.cw-dashboard-students-wrapper >>> .cw-ribbon-nav {
- min-width: 24px;
- padding: 0 1em;
- height: 24px;
- margin-top: 2px;
-}
th {
cursor: pointer;
}
diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
index e17d18e..b0530c4 100644
--- a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
+++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue
@@ -5,13 +5,13 @@
</MountingPortal>
<div v-if="taskGroup" class="cw-tasks-list">
- <CoursewareRibbon :isContentBar="true" :showToolbarButton="false">
- <template #buttons>
+ <ContentBar isContentBar>
+ <template #buttons-left>
<router-link :to="{ name: 'task-groups-index' }">
<StudipIcon shape="category-task" :size="24" />
</router-link>
</template>
- <template #breadcrumbList>
+ <template #breadcrumb-list>
<li>
<router-link :to="{ name: 'task-groups-index' }">
{{ $gettext('Aufgaben') }}
@@ -19,7 +19,7 @@
</li>
<li>{{ taskGroup.attributes['title'] }}</li>
</template>
- </CoursewareRibbon>
+ </ContentBar>
<TaskGroup
:taskGroup="taskGroup"
@@ -67,7 +67,6 @@
import { mapActions, mapGetters } from 'vuex';
import AddFeedbackDialog from './AddFeedbackDialog.vue';
import CompanionBox from '../layouts/CoursewareCompanionBox.vue';
-import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue';
import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue';
import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue';
import EditFeedbackDialog from './EditFeedbackDialog.vue';
@@ -76,12 +75,13 @@ import TaskGroup from './TaskGroup.vue';
import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue';
import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue';
import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue';
+import ContentBar from "../../ContentBar.vue";
export default {
components: {
+ ContentBar,
AddFeedbackDialog,
CompanionBox,
- CoursewareRibbon,
CoursewareTasksActionWidget,
CoursewareTasksDialogDistribute,
EditFeedbackDialog,
@@ -215,10 +215,4 @@ export default {
</script>
<style scoped>
-.cw-tasks-wrapper >>> .cw-ribbon-nav {
- min-width: 24px;
- padding: 0 1em;
- height: 24px;
- margin-top: 2px;
-}
</style>
diff --git a/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue b/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue
index 8988a22..bbf322c 100644
--- a/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue
+++ b/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue
@@ -172,7 +172,7 @@ export default {
return;
}
- const ribbon = document.getElementById('cw-ribbon') ?? document.getElementById('contentbar');
+ const ribbon = document.getElementById('cw-ribbon');
if (ribbon) {
const contentbarRect = ribbon.getBoundingClientRect();
if (ribbon.classList.contains('cw-ribbon-sticky')) {
diff --git a/resources/vue/components/responsive/ResponsiveContentBar.vue b/resources/vue/components/responsive/ResponsiveContentBar.vue
index eb6dd96..d21ae21 100644
--- a/resources/vue/components/responsive/ResponsiveContentBar.vue
+++ b/resources/vue/components/responsive/ResponsiveContentBar.vue
@@ -63,6 +63,22 @@ export default {
}
},
methods: {
+ onCoursewareContentbarMounted(vueInstance) {
+ STUDIP.eventBus.emit('has-contentbar', true);
+
+ this.realContentbar = vueInstance.$refs.header;
+ this.realContentbarSource = vueInstance.$refs.headerContainer;
+ this.realContentbarIconContainer = '.cw-ribbon-nav';
+ this.realContentbarType = 'courseware';
+ this.adjustExistingContentbar(true);
+
+ document.querySelectorAll('.sidebar-widget button span').forEach(item => {
+ item.addEventListener('click', () => this.toggleSidebar());
+ });
+ },
+ onCoursewareContentbarBeforeDestroy(vueInstance) {
+ this.adjustExistingContentbar(false);
+ },
toggleSidebar() {
const sidebar = document.getElementById('sidebar');
@@ -75,8 +91,8 @@ export default {
if (document.documentElement.classList.contains('responsive-display')
&& !document.documentElement.classList.contains('fullscreen-mode')) {
- content.style.display = '';
- pageTitle.style.display = '';
+ content.style.visibility = '';
+ pageTitle.style.visibility = '';
}
if (!document.documentElement.classList.contains('responsive-display')) {
@@ -95,8 +111,8 @@ export default {
&& !document.documentElement.classList.contains('fullscreen-mode')) {
// Set a timeout here so that the content "disappears" after slide-in aninmation is finished.
setTimeout(() => {
- content.style.display = 'none';
- pageTitle.style.display = 'none';
+ content.style.visibility = 'hidden';
+ pageTitle.style.visibility = 'hidden';
}, 300);
}
@@ -134,11 +150,9 @@ export default {
const contentbarContainer = document.getElementById('responsive-contentbar-container');
contentbarContainer.prepend(this.realContentbar);
-
- document.getElementById('content-wrapper').style.marginTop = `${contentbarContainer.clientHeight}px`;
} else {
- this.realContentbar.id = 'contentbar';
- document.getElementById('toggle-sidebar').remove();
+ this.realContentbar.id = 'cw-ribbon';
+ document.getElementById('toggle-sidebar')?.remove();
if (this.realContentbarType === 'courseware') {
this.realContentbar.classList.remove('contentbar');
@@ -148,9 +162,7 @@ export default {
.classList.remove('contentbar-wrapper-right');
}
- document.querySelector(this.realContentbarSource).prepend(this.realContentbar);
-
- document.getElementById('content-wrapper').style.marginTop = 'initial';
+ this.realContentbarSource.append(this.realContentbar);
}
}
},
@@ -196,7 +208,7 @@ export default {
STUDIP.eventBus.emit('has-contentbar', true);
this.realContentbar = cwContentbar;
- this.realContentbarSource = '.cw-structural-element-content > div';
+ this.realContentbarSource = cwContentbar.parentElement;
this.realContentbarIconContainer = '.cw-ribbon-nav';
this.realContentbarType = 'courseware';
this.adjustExistingContentbar(true);
@@ -210,35 +222,16 @@ export default {
})
// Use courseware contentbar instead of this Vue component.
- this.globalOn('courseware-contentbar-mounted', element => {
- STUDIP.eventBus.emit('has-contentbar', true);
-
- this.realContentbar = element.$el.querySelector('header');
- this.realContentbarSource = '.cw-structural-element-content > div';
- this.realContentbarIconContainer = '.cw-ribbon-nav';
- this.realContentbarType = 'courseware';
- this.adjustExistingContentbar(true);
-
- document.querySelectorAll('.sidebar-widget button span').forEach(item => {
- item.addEventListener('click', () => this.toggleSidebar());
- });
- })
-
- this.globalOn('toggle-focus-mode', (state) => {
- const html = document.querySelector('html');
- if (html.classList.contains('responsive-display') || html.classList.contains('fullscreen-mode')) {
- this.adjustExistingContentbar(!state);
- }
- });
-
+ this.globalOn('courseware-contentbar-mounted', this.onCoursewareContentbarMounted)
+ this.globalOn('courseware-contentbar-before-destroy', this.onCoursewareContentbarBeforeDestroy);
},
beforeDestroy() {
+ this.globalOff('courseware-contentbar-mounted', this.onCoursewareContentbarMounted);
+ this.globalOff('courseware-contentbar-before-destroy', this.onCoursewareContentbarBeforeDestroy);
if (this.realContentbar) {
this.adjustExistingContentbar(false);
}
- STUDIP.eventBus.off('toggle-focus-mode');
- STUDIP.eventBus.off('courseware-contentbar-mounted');
}
}
</script>
diff --git a/resources/vue/components/table-of-contents.ts b/resources/vue/components/table-of-contents.ts
new file mode 100644
index 0000000..9dd74dd
--- /dev/null
+++ b/resources/vue/components/table-of-contents.ts
@@ -0,0 +1,19 @@
+import { Icon } from '../../studip';
+
+// Corresponds to the PHP class TOCItem
+export interface TOCItem {
+ title: string;
+ url: string;
+ parent?: TOCItem;
+ children: TOCItem[];
+ active: boolean;
+ icon?: Icon;
+}
+
+// Depth-first traversal of a TOCItem hierachy starting at its root.
+export function traverse(tocRoot: TOCItem, callback: (item: TOCItem) => void) {
+ callback(tocRoot);
+ for (let tocItem of tocRoot.children) {
+ traverse(tocItem, callback);
+ }
+}
diff --git a/resources/vue/mixins/courseware/export.js b/resources/vue/mixins/courseware/export.js
index 3d9e33a..f3002c3 100644
--- a/resources/vue/mixins/courseware/export.js
+++ b/resources/vue/mixins/courseware/export.js
@@ -196,6 +196,7 @@ export default {
formData.append("data[difficulty_start]", difficulty_start);
formData.append("data[difficulty_end]", difficulty_end);
formData.append("data[category]", 'elearning');
+ formData.append(STUDIP.CSRF_TOKEN.name, STUDIP.CSRF_TOKEN.value);
axios({
method: 'post',
@@ -256,13 +257,13 @@ export default {
if (fileType === 'file-refs') {
await this.loadFileRefsById({id: fileId});
let fileRef = this.fileRefsById({id: fileId});
-
+
let fileRefData = {};
fileRefData.id = fileRef.id;
fileRefData.attributes = fileRef.attributes;
fileRefData.related_element_id = element.id;
fileRefData.folder = null;
-
+
this.exportFiles.json.push(fileRefData);
this.exportFiles.download[fileRef.id] = {
folder: null,
diff --git a/resources/vue/store/StudipStore.js b/resources/vue/store/StudipStore.js
index 7a733c0..7c404a4 100644
--- a/resources/vue/store/StudipStore.js
+++ b/resources/vue/store/StudipStore.js
@@ -1,8 +1,10 @@
-export default {
+import { eventBus, store } from '../../assets/javascripts/chunks/vue';
+
+const studipStore = {
namespaced: true,
- state () {
- return {...STUDIP.config};
+ state() {
+ return { ...STUDIP.config, consumeMode: false };
},
getters: {
getConfig: (state) => (key) => {
@@ -10,6 +12,13 @@ export default {
throw new Error(`Invalid access to unknown configuration item "${key}"`);
}
return state[key];
- }
- }
-}
+ },
+ },
+};
+
+// Make the current state of "focus mode" (fullscreen) available to Vue components.
+eventBus.on('switch-focus-mode', (mode) => {
+ store.state.studip.consumeMode = mode;
+});
+
+export default studipStore;
diff --git a/resources/vue/store/courseware/courseware-public.module.js b/resources/vue/store/courseware/courseware-public.module.js
index 1982fe2..403cfe4 100644
--- a/resources/vue/store/courseware/courseware-public.module.js
+++ b/resources/vue/store/courseware/courseware-public.module.js
@@ -1,7 +1,6 @@
const getDefaultState = () => {
return {
blockAdder: {},
- consumeMode: true,
containerAdder: false,
context: null,
courseware: {},
@@ -22,10 +21,6 @@ const getters = {
return state.blockAdder;
},
- consumeMode(state) {
- return state.consumeMode;
- },
-
containerAdder(state) {
return state.containerAdder;
},
@@ -71,10 +66,6 @@ export const state = { ...initialState };
export const actions = {
// setters
- coursewareConsumeMode({ commit }, consumeMode) {
- commit('setConsumeMode', consumeMode);
- },
-
coursewareContainerAdder(context, adder) {
context.commit('setContainerAdder', adder);
},
@@ -134,10 +125,6 @@ export const mutations = {
state.courseware = data;
},
- setConsumeMode(state, consumeMode) {
- state.consumeMode = consumeMode;
- },
-
setContainerAdder(state, containerAdder) {
state.containerAdder = containerAdder;
},
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index 53ef6aa..cb844e1 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -989,10 +989,6 @@ export const actions = {
context.commit('coursewareStyleCompanionOverlaySet', companion_overlay_style);
},
- coursewareConsumeMode(context, mode) {
- context.commit('coursewareConsumeModeSet', mode);
- },
-
setHttpClient({ commit }, httpClient) {
commit('setHttpClient', httpClient);
},
@@ -1581,10 +1577,6 @@ export const mutations = {
state.styleCompanionOverlay = data;
},
- coursewareConsumeModeSet(state, data) {
- state.consumeMode = data;
- },
-
setHttpClient(state, httpClient) {
state.httpClient = httpClient;
},
diff --git a/templates/vue-app.php b/templates/vue-app.php
index 2c34929..7f0841b 100644
--- a/templates/vue-app.php
+++ b/templates/vue-app.php
@@ -4,11 +4,16 @@
* @var string $baseComponent
* @var array $props
* @var array $storeData
+ * @var array $slots
*/
?>
<? foreach ($storeData as $store => $data): ?>
<script type="application/json" id="vue-store-data-<?= htmlReady($store) ?>"><?= json_encode($data) ?></script>
<? endforeach; ?>
<div <?= arrayToHtmlAttributes($attributes) ?>>
- <<?= strtokebabcase($baseComponent) ?> <?= arrayToHtmlAttributes($props) ?>/>
+ <<?= strtokebabcase($baseComponent) ?> <?= arrayToHtmlAttributes($props) ?>>
+ <? foreach ($slots as $slotname => $slot): ?>
+ <template #<?= htmlReady($slotname) ?>><?= $slot ?></template>
+ <? endforeach; ?>
+ </<?= strtokebabcase($baseComponent) ?>>
</div>