diff --git a/classes/category_helper.php b/classes/category_helper.php new file mode 100644 index 00000000..632fd889 --- /dev/null +++ b/classes/category_helper.php @@ -0,0 +1,145 @@ +. +// (c) 2016 GTN - Global Training Network GmbH . + +namespace block_exaport; + +defined('MOODLE_INTERNAL') || die(); + +class category_helper { + + /** + * Build a category tree keyed by parent id. + * + * @param array $categories + * @return array + */ + public static function build_by_parent(array $categories): array { + $categoriesbyparent = []; + foreach ($categories as $category) { + if (!isset($categoriesbyparent[$category->pid])) { + $categoriesbyparent[$category->pid] = []; + } + $categoriesbyparent[$category->pid][] = $category; + } + + return $categoriesbyparent; + } + + /** + * Build the full hierarchical path name for a category, e.g. "haustiere / hunde". + * + * @param int $categoryid The category id. + * @param array $categories Associative array of all categories keyed by id (must have ->name and ->pid). + * @return string The full path name with " / " separators. + */ + public static function full_path_name(int $categoryid, array $categories): string { + $parts = []; + $id = $categoryid; + $visited = []; + while ($id && isset($categories[$id])) { + if (isset($visited[$id])) { + break; // Prevent infinite loop on circular references. + } + $visited[$id] = true; + $parts[] = $categories[$id]->name; + $id = $categories[$id]->pid ?? 0; + } + $parts = array_reverse($parts); + return implode(' / ', $parts); + } + + /** + * Load all items for flat mode and attach flatcategories to each item. + * + * @param int $userid The user whose items to load. + * @param array $categories All categories keyed by id (for path name resolution). + * @param string $sqlsort SQL ORDER BY clause. + * @param array|null $allowedcategoryids Category filter behavior: + * - null: load all categories for the viewed user in flat mode; this is all categories for your own items, or only that + * other user's own categories when viewing someone else's items. + * - empty array: return no items. + * - non-empty array: only include these category IDs and remove items with no matching categories. + * @return array The items array with ->flatcategories populated. + */ + public static function load_flat_items(int $userid, array $categories, string $sqlsort, + ?array $allowedcategoryids = null): array { + global $DB, $USER; + + if ($allowedcategoryids !== null && empty($allowedcategoryids)) { + // Keep the shared flat-mode behavior while avoiding an empty IN() SQL clause. + return []; + } + if ($allowedcategoryids !== null) { + $items = block_exaport_get_items_by_category_and_user(0, $allowedcategoryids, $sqlsort, true); + } else { + // this gets ALL the items of that user... e.g. unshared ones as well. As a teacher, this loads all the students items + // but they get filtered a few lines later with the unset() + $items = block_exaport_get_items_by_category_and_user($userid, null, $sqlsort); + } + + if (!$items) { + return []; + } + + $itemids = array_keys($items); + [$iteminsql, $iteminparams] = $DB->get_in_or_equal($itemids, SQL_PARAMS_QM); + + // Belt-and-suspenders: restrict to the viewed user's own categories, + // even though items are already scoped by userid. + $is_viewing_other_user = $allowedcategoryids === null && (int)$userid !== (int)$USER->id; + + $sql = "SELECT ic.id AS icid, ic.itemid, c.id, c.name, c.pid + FROM {block_exaportitemcate} ic + JOIN {block_exaportcate} c ON c.id = ic.cateid + WHERE ic.itemid $iteminsql"; + $params = $iteminparams; + + // Belt-and-suspenders: restrict to the viewed user's own categories, + // even though items are already scoped by userid. + if ($is_viewing_other_user) { + $sql .= " AND c.userid = ?"; + $params[] = $userid; + } + + if ($allowedcategoryids !== null) { + [$catinsql, $catinparams] = $DB->get_in_or_equal($allowedcategoryids, SQL_PARAMS_QM); + $sql .= " AND c.id $catinsql"; + $params = array_merge($params, $catinparams); + } + + $sql .= " ORDER BY c.name ASC"; + $itemcategories = $DB->get_records_sql($sql, $params); + + $categoriesbyitem = []; + foreach ($itemcategories as $itemcategory) { + $itemcategory->name = self::full_path_name($itemcategory->id, $categories); + if (!isset($categoriesbyitem[$itemcategory->itemid])) { + $categoriesbyitem[$itemcategory->itemid] = []; + } + $categoriesbyitem[$itemcategory->itemid][] = $itemcategory; + } + + foreach ($items as $itemid => $item) { + $item->flatcategories = $categoriesbyitem[$item->id] ?? []; + if ($allowedcategoryids !== null && !$item->flatcategories) { + unset($items[$itemid]); // this is crucial! This unsets all items that should not be displayed + } + } + + return $items; + } +} diff --git a/classes/output/artefact_card.php b/classes/output/artefact_card.php new file mode 100644 index 00000000..1a9bab54 --- /dev/null +++ b/classes/output/artefact_card.php @@ -0,0 +1,114 @@ +. +// (c) 2016 GTN - Global Training Network GmbH . + +namespace block_exaport\output; + +defined('MOODLE_INTERNAL') || die(); + +use renderable; +use renderer_base; +use templatable; + +/** + * Abstract base class for artefact card output objects (Bootstrap layout). + * + * Holds the shared constructor, properties, and data that is common to + * both the flat/grid card and the folder-navigation card. + */ +abstract class artefact_card implements renderable, templatable { + + /** @var \stdClass $item */ + protected $item; + + /** @var int $courseid */ + protected $courseid; + + /** @var string $type */ + protected $type; + + /** @var int $categoryid */ + protected $categoryid; + + /** @var \stdClass $currentcategory */ + protected $currentcategory; + + /** + * Constructor. + * + * @param \stdClass $item The artefact/item record. + * @param int $courseid The course id. + * @param string $type Access type, e.g. 'mine' or 'shared'. + * @param int $categoryid The current category id (used for delete URL). + * @param \stdClass $currentcategory The currently active category. + */ + public function __construct(\stdClass $item, int $courseid, string $type, int $categoryid, + \stdClass $currentcategory) { + $this->item = $item; + $this->courseid = $courseid; + $this->type = $type; + $this->categoryid = $categoryid; + $this->currentcategory = $currentcategory; + } + + /** + * Return the data fields shared by all artefact card variants. + * + * @return array + */ + protected function base_export_data(): array { + global $CFG, $USER; + + $item = $this->item; + $courseid = $this->courseid; + $type = $this->type; + $categoryid = $this->categoryid; + + $url = $CFG->wwwroot . '/blocks/exaport/shared_item.php?courseid=' . $courseid + . '&access=portfolio/id/' . $item->userid . '&itemid=' . $item->id; + + // Build category IDs for client-side filtering. + $itemcatids = []; + if (!empty($item->flatcategories) && is_array($item->flatcategories)) { + foreach ($item->flatcategories as $cat) { + $itemcatids[] = (int)$cat->id; + } + } + + $cattype = ($type == 'shared') ? '&cattype=shared' : ''; + $isownitem = ($item->userid == $USER->id); + $commentcount = (int)($item->comments ?? 0); + + return [ + 'itemnamelower' => strtolower($item->name), + 'catids' => implode(',', $itemcatids), + 'timemodified' => (int)$item->timemodified, + 'itemid' => (int)$item->id, + 'url' => $url, + 'itemname' => $item->name, + 'isownitem' => $isownitem, + 'editurl' => $CFG->wwwroot . '/blocks/exaport/item.php?courseid=' . $courseid + . '&id=' . $item->id . '&action=edit' . $cattype, + 'editicon' => block_exaport_fontawesome_icon('pen-to-square', 'regular', 1), + 'deleteurl' => $CFG->wwwroot . '/blocks/exaport/item.php?courseid=' . $courseid + . '&id=' . $item->id . '&action=delete&categoryid=' . $categoryid . $cattype, + 'deleteicon' => block_exaport_fontawesome_icon('trash-can', 'regular', 1, [], [], [], '', [], [], [], ['exaport-remove-icon']), + 'hascomments' => $commentcount > 0, + 'commentcount' => $commentcount, + 'dateformatted' => date('d.m.Y H:i', $item->timemodified), + ]; + } +} diff --git a/classes/output/artefact_card_flat.php b/classes/output/artefact_card_flat.php new file mode 100644 index 00000000..1097e895 --- /dev/null +++ b/classes/output/artefact_card_flat.php @@ -0,0 +1,43 @@ +. +// (c) 2016 GTN - Global Training Network GmbH . + +namespace block_exaport\output; + +defined('MOODLE_INTERNAL') || die(); + +use renderer_base; + +/** + * Output class for the artefact card in flat/grid mode (Bootstrap layout). + * + * Extends artefact_card_folder and adds category badge chips shown only in flat mode. + * Rendered via block_exaport/artefact_card_folder template (see renderer.php). + */ +class artefact_card_flat extends artefact_card_folder { + + /** + * Export the data required by the mustache template. + * + * @param renderer_base $output + * @return array + */ + public function export_for_template(renderer_base $output): array { + return parent::export_for_template($output) + [ + 'categorybadges' => block_exaport_render_item_category_badges($this->item), + ]; + } +} diff --git a/classes/output/artefact_card_folder.php b/classes/output/artefact_card_folder.php new file mode 100644 index 00000000..d6b39913 --- /dev/null +++ b/classes/output/artefact_card_folder.php @@ -0,0 +1,72 @@ +. +// (c) 2016 GTN - Global Training Network GmbH . + +namespace block_exaport\output; + +defined('MOODLE_INTERNAL') || die(); + +use context_user; +use renderer_base; + +/** + * Output class for the artefact card in folder-navigation mode (Bootstrap layout). + * + * Renders block_exaport/artefact_card_folder. + */ +class artefact_card_folder extends artefact_card { + + /** + * Export the data required by the mustache template. + * + * @param renderer_base $output + * @return array + */ + public function export_for_template(renderer_base $output): array { + $item = $this->item; + $iconTypeProps = block_exaport_item_icon_type_options($item->type); + $typelabel = get_string($item->type, 'block_exaport'); + + $introtext = ''; + if (!empty($item->intro)) { + $intro = file_rewrite_pluginfile_urls($item->intro, 'pluginfile.php', + context_user::instance($item->userid)->id, + 'block_exaport', 'item_content', + 'portfolio/id/' . $item->userid . '/itemid/' . $item->id); + $introtext = shorten_text(trim(strip_tags($intro)), 140, true); + } + + $base = $this->base_export_data(); + $commentcount = $base['commentcount']; + $commentlabel = $commentcount . ' ' . block_exaport_get_string($commentcount === 1 ? 'comment' : 'comments'); + + return $base + [ + 'typeicon' => '', + 'ellipsisicon' => block_exaport_fontawesome_icon('ellipsis-vertical', 'solid', 1), + 'viewlabel' => block_exaport_get_string('view'), + 'viewicon' => block_exaport_fontawesome_icon('eye', 'regular', 1), + 'canedit' => $base['isownitem'], + 'editlabel' => block_exaport_get_string('edit'), + 'candelete' => $base['isownitem'] && block_exaport_item_is_editable($item->id), + 'deletelabel' => block_exaport_get_string('delete'), + 'introtext' => $introtext, + 'compbadge' => block_exaport_get_item_comp_footer_badge($item), + 'commentlabel' => $commentlabel, + ]; + } +} diff --git a/classes/output/category_card.php b/classes/output/category_card.php new file mode 100644 index 00000000..93a91232 --- /dev/null +++ b/classes/output/category_card.php @@ -0,0 +1,104 @@ +. +// (c) 2016 GTN - Global Training Network GmbH . + +namespace block_exaport\output; + +defined('MOODLE_INTERNAL') || die(); + +use renderable; +use renderer_base; +use templatable; + +/** + * Output class for the category card tile (Bootstrap/folder-mode layout). + * + * Renders block_exaport/category_card. + */ +class category_card implements renderable, templatable { + + /** @var \stdClass $category */ + protected $category; + + /** @var int $courseid */ + protected $courseid; + + /** @var string $type */ + protected $type; + + /** @var \stdClass|null $parentcategory */ + protected $parentcategory; + + /** + * Constructor. + * + * @param \stdClass $category The category record. + * @param int $courseid The course id. + * @param string $type Access type, e.g. 'mine' or 'shared'. + * @param \stdClass $currentcategory The currently active category (unused in context build but kept for API parity). + * @param \stdClass|null $parentcategory When non-null, this tile links up to the parent category. + */ + public function __construct(\stdClass $category, int $courseid, string $type, \stdClass $currentcategory, + ?\stdClass $parentcategory = null) { + $this->category = $category; + $this->courseid = $courseid; + $this->type = $type; + $this->parentcategory = $parentcategory; + } + + /** + * Export the data required by the mustache template. + * + * @param renderer_base $output + * @return array + */ + public function export_for_template(renderer_base $output): array { + global $CFG; + + $isparenttile = (bool)$this->parentcategory; + $tiletargetid = $isparenttile ? (int)$this->parentcategory->id : (int)$this->category->id; + $tilename = $isparenttile ? $this->parentcategory->name : $this->category->name; + $tileurl = $isparenttile ? (string)$this->parentcategory->url : (string)$this->category->url; + $outerclasses = $isparenttile ? 'col mb-4 exaport-folder-category' : 'col col-card-folder mb-4 exaport-folder-category'; + $tilefixedclass = $isparenttile ? 'excomdos_tile_fixed ' : ''; + + return [ + 'outerclasses' => $outerclasses, + 'tilenamelower' => strtolower($tilename), + 'isparenttile' => $isparenttile, + 'tiletargetid' => $tiletargetid, + 'tilefixedclass' => $tilefixedclass, + 'tileurl' => $tileurl, + 'tilename' => $tilename, + 'typemine' => ($this->type == 'mine'), + 'editurl' => $CFG->wwwroot . '/blocks/exaport/category.php?courseid=' . $this->courseid + . '&id=' . $this->category->id . '&action=edit', + 'deleteurl' => $CFG->wwwroot . '/blocks/exaport/category.php?courseid=' . $this->courseid + . '&id=' . $this->category->id . '&action=delete', + 'ellipsisicon' => block_exaport_fontawesome_icon('ellipsis-vertical', 'solid', 1), + 'viewicon' => block_exaport_fontawesome_icon('eye', 'regular', 1), + 'editicon' => block_exaport_fontawesome_icon('pen-to-square', 'regular', 1), + 'deleteicon' => block_exaport_fontawesome_icon('trash-can', 'regular', 1, [], [], [], '', [], [], [], ['exaport-remove-icon']), + 'viewlabel' => block_exaport_get_string('view'), + 'editlabel' => block_exaport_get_string('edit'), + 'deletelabel' => block_exaport_get_string('delete'), + 'folderupicon' => block_exaport_fontawesome_icon('folder-open', 'regular', 1, ['icon', 'fa-fw', 'me-1'], [], + ['data-bs-toggle' => 'tooltip', 'data-bs-placement' => 'top', + 'data-bs-title' => block_exaport_get_string('category_up')], 'up'), + 'categorylabel' => block_exaport_get_string('category'), + ]; + } +} diff --git a/css/eportfolio-cards.css b/css/eportfolio-cards.css new file mode 100644 index 00000000..b215f31a --- /dev/null +++ b/css/eportfolio-cards.css @@ -0,0 +1,191 @@ +#exaport .tertiary-exabis-navigation { + padding-bottom: 25px; +} +#exaport .tertiary-exabis-navigation .navigation { + border-bottom: 1px solid var(--bs-border-color); + background-color: #fff; + margin: 0 -0.5rem; + padding: 0 0.5rem; +} +#exaport .exaport-portfolio-toolbar .input-group, +#exaport .exaport-portfolio-toolbar .form-select { + width: 100%; +} +#exaport .exaport-portfolio-toolbar .icon { + margin-right: 0; +} +#exaport .card-eportfolio .card-top { + padding: 1rem 1rem 0 1rem; +} +#exaport .card-eportfolio .card-title { + line-height: 1.4; +} +#exaport .card-eportfolio .card-title a:hover, +#exaport .card-eportfolio .card-title a:focus { + text-decoration: none; + outline: 0 !important; + box-shadow: none !important; +} +#exaport .card-eportfolio .card-title .icon { + color: #000; + color: rgba(0, 0, 0, 0.796); +} +#exaport .card-eportfolio .card-body { + padding: .5rem 1rem 1rem 1rem; +} +#exaport .card-eportfolio .eportfolio-categories { + font-size: .8rem; +} +#exaport .card-eportfolio .eportfolio-categories .badge { + font-size: .72rem; + background-color: #e9ecef !important; +} +#exaport .card-eportfolio .eportoflio-share .icon { + margin-right: 0; +} +#exaport .card-eportfolio .eportoflio-share .icon.icon-shared, +#exaport .card-eportfolio .eportoflio-comment .icon.icon-comment { + margin-right: .2rem; +} +#exaport .card-eportfolio .eportoflio-date, +#exaport .card-eportfolio .eportfolio-share-count, +#exaport .card-eportfolio .eportfolio-comment-count { + font-size: .8rem; +} +#exaport .eportfolio-card-more .dropdown-toggle { + color: #000; +} +#exaport .eportfolio-card-more .dropdown-toggle:after { + content: ""; + display: none; +} +#exaport .col-card-collection-group { + position: relative; +} +#exaport .card-collection-outer { + position: relative; +} +#exaport .card-eportfolio { + position: relative; + z-index: 3; + background: #fff; + border: .0625rem solid rgba(0,0,0,.175); + border-radius: .5rem; + padding-left: 0 !important; + padding-right: 0 !important; +} +#exaport .card-stack-layer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #fff; + border: .0625rem solid rgba(0,0,0,.175); + border-radius: .5rem; + pointer-events: none; +} +#exaport .card-stack-layer-1 { + top: -.2rem; + left: .2rem; + z-index: 2; +} +#exaport .card-stack-layer-2 { + top: -.4rem; + left: .4rem; + z-index: 1; + box-shadow: 2px 2px 4px rgba(0,0,0,.15); +} +#exaport .col-card-folder .card-eportfolio { + position: relative; + border-radius: 0 0.75rem 0.75rem 0rem; + margin-top: 1.2rem; +} +#exaport .col-card-folder .card-eportfolio::before { + content: ""; + position: absolute; + top: -1.15rem; + left: -1px; + width: 8rem; + height: 1.2rem; + background: var(--bs-card-bg, #fff); + border: var(--bs-card-border-width, 1px) solid var(--bs-card-border-color, rgba(0,0,0,.175)); + border-radius: 0.75rem 0.75rem 0 0; + background-color: #efefef; + border-bottom: 0; +} +#exaport .col-card-folder .card-eportfolio::after { + content: ""; + position: absolute; + top: -1.15rem; + left: 6.7rem; + width: 2.2rem; + height: 1.2rem; + background: var(--bs-card-bg, #fff); + border-top: var(--bs-card-border-width, 1px) solid var(--bs-card-border-color, rgba(0,0,0,.175)); + border-right: var(--bs-card-border-width, 1px) solid var(--bs-card-border-color, rgba(0,0,0,.175)); + transform: skewX(35deg); + transform-origin: left bottom; + border-radius: 0 0.45rem 0 0; + background-color: #efefef; +} +#exaport .col-card-folder .card-eportfolio { + height: calc(100% - 1.2rem) !important; +} +.tooltip .tooltip-inner { + text-align: left; +} +.tooltiplist { + margin: 0; + padding-left: 1rem; +} +.tooltiplist li { + margin: 0; + padding: 0; +} +#exaport .exaport-category-view .badge { + font-size: 0.70rem; + padding: 0.35rem 0.65rem; + border-radius: 0.7rem; +} +#exaport .eportfolio-categories .badge { + font-size: .78rem; + cursor: default; +} +/* Uniform card height for both item and category column wrappers */ +#exaport .exaport-flat-item, +#exaport .exaport-folder-category { + height: 13rem; +} +/* Artefact cards (items) fill their column fully */ +#exaport .exaport-flat-item .card-eportfolio { + height: 100% !important; +} +/* card-body stretches between card-top and card-footer, laid out as a column */ +#exaport .card-eportfolio .card-body { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-height: 0; +} +/* Text zone: grows to fill remaining space, text clamped to 2 lines */ +#exaport .card-eportfolio .card-body-text { + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} +#exaport .card-eportfolio .card-body-text .card-text { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 0; +} +/* Chips zone: fixed single-row height, overflowing chips clipped with a right-edge fade */ +#exaport .card-eportfolio .card-body-chips { + flex: 0 0 1.8rem; + overflow: hidden; + white-space: nowrap; + -webkit-mask-image: linear-gradient(to right, black 70%, transparent 100%); + mask-image: linear-gradient(to right, black 70%, transparent 100%); +} diff --git a/lib/externlib.php b/lib/externlib.php index bd002325..50beb186 100644 --- a/lib/externlib.php +++ b/lib/externlib.php @@ -29,12 +29,77 @@ function block_exaport_get_user_from_hash($hash) { } } +/** + * Fetch category badge HTML for a single item in the shared detail view. + * + * @param int $itemid + * @param int $userid Owner of the item (used to build full path names). + * @return string HTML string of category badges, or empty string when no categories. + */ +function block_exaport_extern_item_category_badges(int $itemid, int $userid): string { + global $DB; + + // Load all categories belonging to this item's owner so we can build full path names. + $allcategories = $DB->get_records('block_exaportcate', ['userid' => $userid], '', 'id, name, pid'); + + // Resolve the full path name for a given category id. + $fullpath = function(int $catid) use ($allcategories): string { + $parts = []; + $id = $catid; + $visited = []; + while ($id && isset($allcategories[$id])) { + if (isset($visited[$id])) { + break; + } + $visited[$id] = true; + $parts[] = $allcategories[$id]->name; + $id = (int)($allcategories[$id]->pid ?? 0); + } + return implode(' / ', array_reverse($parts)); + }; + + $rows = $DB->get_records_sql( + "SELECT ic.id AS icid, c.id, c.name + FROM {block_exaportitemcate} ic + JOIN {block_exaportcate} c ON c.id = ic.cateid + WHERE ic.itemid = ? + ORDER BY c.name ASC", + [$itemid] + ); + + if (!$rows) { + return ''; + } + + $badges = []; + foreach ($rows as $row) { + $label = $fullpath((int)$row->id) ?: format_string($row->name); + $parts = explode(' / ', $label); + $shortlabel = trim(end($parts)); + $attrs = [ + 'class' => 'badge badge-secondary', + 'data-bs-toggle' => 'tooltip', + 'data-bs-placement' => 'top', + 'data-bs-title' => $label, + ]; + $badges[] = html_writer::tag('span', $shortlabel, $attrs); + } + + return html_writer::div(implode(' ', $badges), 'eportfolio-categories'); +} + function block_exaport_print_extern_item($item, $access) { - global $CFG, $OUTPUT; + global $CFG, $OUTPUT, $DB; echo $OUTPUT->heading(format_string($item->name)); $tags = \core_tag_tag::get_item_tags('block_exaport', 'block_exaportitem', $item->id); echo $OUTPUT->tag_list($tags, null, 'exaport-artifact-tags', 0, null, false); + // Display category badges. + $categorybadges = block_exaport_extern_item_category_badges($item->id, $item->userid); + if ($categorybadges) { + echo $categorybadges; + } + $boxcontent = ''; $filescontent = ''; if ($files = block_exaport_get_item_files($item)) { diff --git a/lib/lib.php b/lib/lib.php index 303facfb..9e4391fc 100644 --- a/lib/lib.php +++ b/lib/lib.php @@ -339,6 +339,7 @@ function block_exaport_init_js_css() { $PAGE->requires->js('/blocks/exaport/javascript/exaport.js', true); $PAGE->requires->css('/blocks/exaport/css/styles.css'); + $PAGE->requires->css('/blocks/exaport/css/eportfolio-cards.css'); $scriptname = preg_replace('!\.[^\.]+$!', '', basename($_SERVER['PHP_SELF'])); if (file_exists($CFG->dirroot . '/blocks/exaport/css/' . $scriptname . '.css')) { diff --git a/renderer.php b/renderer.php index 63c04c33..10dcb66b 100644 --- a/renderer.php +++ b/renderer.php @@ -19,6 +19,7 @@ require_once(__DIR__ . '/inc.php'); class block_exaport_renderer extends plugin_renderer_base { + /** * in moodle33 pix_url was renamed to image_url */ @@ -40,5 +41,17 @@ public function get_theme_dir() { public function get_theme_config() { return $this->page->theme; } + + /** + * Render an artefact card in flat/grid mode using the shared folder card template. + * + * The flat card is identical to the folder card except it also shows category chips. + * + * @param \block_exaport\output\artefact_card_flat $card + * @return string + */ + public function render_artefact_card_flat(\block_exaport\output\artefact_card_flat $card): string { + return $this->render_from_template('block_exaport/artefact_card_folder', $card->export_for_template($this)); + } } diff --git a/templates/artefact_card_folder.mustache b/templates/artefact_card_folder.mustache new file mode 100644 index 00000000..da454f0d --- /dev/null +++ b/templates/artefact_card_folder.mustache @@ -0,0 +1,88 @@ +{{! + Artefact card for the bootstrap layout (moodle_bootstrap). + Used by block_exaport_artefact_template_bootstrap_card() in view_items.php + for both folder-navigation mode and flat/grid mode. + + Context variables: + url - URL to the artefact detail page + itemnamelower - Lowercase item name for data-item-name attribute + timemodified - Unix timestamp for data-item-date attribute + catids - Comma-separated category IDs for data-category-ids attribute + itemid - Integer item id + typeicon - Pre-rendered HTML for the type icon (with tooltip) + itemname - Display name of the item (auto-escaped by mustache) + ellipsisicon - Pre-rendered HTML for the "⋮" dropdown toggle icon + viewlabel - Localised "View" label + viewicon - Pre-rendered HTML for the view icon + canedit - Boolean: show "Edit" menu entry + editurl - URL for the edit action + editicon - Pre-rendered HTML for the edit icon + editlabel - Localised "Edit" label + candelete - Boolean: show "Delete" menu entry + deleteurl - URL for the delete action + deleteicon - Pre-rendered HTML for the delete icon + deletelabel - Localised "Delete" label + introtext - Short plain-text intro excerpt (empty string when absent) + dateformatted - Human-readable date string for the card footer + compbadge - Pre-rendered HTML for the competence badge (may be empty) + hascomments - Boolean: item has at least one comment + commentcount - Integer comment count + commentlabel - Localised comment count tooltip string + categorybadges - Pre-rendered HTML for category badge pills (flat mode only, may be empty) +}} +
+
+
+
+ {{{typeicon}}}{{itemname}} +
+ + + + +
+
+
+ {{#introtext}}

{{introtext}}

{{/introtext}} +
+
+ {{{categorybadges}}} +
+
+ +
+
diff --git a/templates/category_card.mustache b/templates/category_card.mustache new file mode 100644 index 00000000..59755857 --- /dev/null +++ b/templates/category_card.mustache @@ -0,0 +1,61 @@ +{{! + Category card tile for the bootstrap layout (moodle_bootstrap). + Used by block_exaport_category_template_bootstrap_card() in view_items.php. + + Context variables: + outerclasses - CSS class string for the outer wrapper div + tilenamelower - Lowercase category name for data-item-name attribute + isparenttile - Boolean: true when this tile links *up* to the parent category + tiletargetid - Integer id of the target category (for drag-drop) + tilefixedclass - Extra CSS class string (non-empty only for parent tile) + tileurl - URL the tile links to + tilename - Display name of the category (auto-escaped by mustache) + typemine - Boolean: current user owns this category (show edit/delete) + editurl - URL for the edit action + deleteurl - URL for the delete action + ellipsisicon - Pre-rendered HTML for the "⋮" dropdown toggle icon + viewicon - Pre-rendered HTML for the "view" menu item icon + editicon - Pre-rendered HTML for the "edit" menu item icon + deleteicon - Pre-rendered HTML for the "delete" menu item icon + viewlabel - Localised "View" label + editlabel - Localised "Edit" label + deletelabel - Localised "Delete" label + folderupicon - Pre-rendered HTML for the folder-open-up icon (parent tile only) + categorylabel - Localised "Category" tooltip text (regular tile only) +}} + diff --git a/version.php b/version.php index 3ae1d1b6..cdbe8a2b 100644 --- a/version.php +++ b/version.php @@ -19,6 +19,6 @@ $plugin->component = 'block_exaport'; $plugin->release = '5.1'; -$plugin->version = 2026052903; +$plugin->version = 2026060202; $plugin->requires = 2021051700; // moodle 3.11 $plugin->maturity = MATURITY_STABLE; diff --git a/view_items.php b/view_items.php index 6710e7df..2de844a3 100644 --- a/view_items.php +++ b/view_items.php @@ -164,7 +164,7 @@ } // Build a tree according to parent. - $categoriesbyparent = block_exaport_build_categories_by_parent($categories); + $categoriesbyparent = \block_exaport\category_helper::build_by_parent($categories); // The main root category for student. $rootcategory = block_exaport_get_root_category($selecteduser->id); @@ -197,7 +197,7 @@ $currentcategory = $rootcategory; $parentcategory = null; $subcategories = []; - $items = block_exaport_load_flat_items($selecteduser->id, $categories, $sqlsort); + $items = \block_exaport\category_helper::load_flat_items($selecteduser->id, $categories, $sqlsort); } else { // Common items. $items = $DB->get_records_sql(" @@ -295,7 +295,7 @@ function category_allowed($selecteduser, $categories, $category) { $category->icon = block_exaport_get_category_icon($category); } // Build a tree according to parent. - $categoriesbyparent = block_exaport_build_categories_by_parent($categories); + $categoriesbyparent = \block_exaport\category_helper::build_by_parent($categories); if (!isset($categories[$categoryid])) { throw new moodle_exception('not allowed'); @@ -334,7 +334,7 @@ function category_allowed($selecteduser, $categories, $category) { $currentcategory = $rootcategory; $parentcategory = null; $subcategories = []; - $items = block_exaport_load_flat_items($selecteduser->id, $categories, $sqlsort, array_keys($allowedcategories)); + $items = \block_exaport\category_helper::load_flat_items($selecteduser->id, $categories, $sqlsort, array_keys($allowedcategories)); } else { $usercondition = ' i.userid = ' . intval($selecteduser->id) . ' '; if ($type == 'shared') { @@ -373,7 +373,7 @@ function category_allowed($selecteduser, $categories, $category) { } // Build a tree according to parent. - $categoriesbyparent = block_exaport_build_categories_by_parent($categories); + $categoriesbyparent = \block_exaport\category_helper::build_by_parent($categories); // The main root category. $rootcategory = block_exaport_get_root_category(); @@ -407,9 +407,9 @@ function category_allowed($selecteduser, $categories, $category) { // Filter out the root category (id=0) which is a virtual placeholder, not a real DB category. $usercategoryids = array_filter(array_keys($categories), fn($id) => $id > 0); if ($show_otherusers) { - $items = block_exaport_load_flat_items($USER->id, $categories, $sqlsort, $usercategoryids ?: null); + $items = \block_exaport\category_helper::load_flat_items($USER->id, $categories, $sqlsort, $usercategoryids ?: null); } else { - $items = block_exaport_load_flat_items($USER->id, $categories, $sqlsort, null); + $items = \block_exaport\category_helper::load_flat_items($USER->id, $categories, $sqlsort, null); } } else { // Folder mode keeps legacy category navigation behavior. @@ -523,7 +523,7 @@ function block_exaport_print_category_select($categoriesbyparent, $currentcatego if ($type == 'shared' && !category_allowed($selecteduser, $categories, $category)) { continue; } - $filtercategories[(int)$category->id] = block_exaport_category_full_path_name($category->id, $categories); + $filtercategories[(int)$category->id] = \block_exaport\category_helper::full_path_name($category->id, $categories); } echo '
'; @@ -616,6 +616,16 @@ function block_exaport_print_category_select($categoriesbyparent, $currentcatego echo '
'; $PAGE->requires->js_call_amd('block_exaport/view_items_state', 'init', [$folderlayout, $layout]); +$PAGE->requires->js_amd_inline(' + document.addEventListener("DOMContentLoaded", function () { + if (typeof bootstrap === "undefined" || !bootstrap.Tooltip) { + return; + } + document.querySelectorAll("[data-bs-toggle=\'tooltip\']").forEach(function (el) { + bootstrap.Tooltip.getOrCreateInstance(el); + }); + }); +'); if ($layout == 'folder') { echo '
'; @@ -918,7 +928,7 @@ function block_exaport_print_category_select($categoriesbyparent, $currentcatego } foreach ($items as $item) { - echo block_exaport_artefact_list_item($item, $courseid, $type, $categoryid, $currentcategory); + echo block_exaport_artefact_list_item($item, $courseid, $type, $categoryid, $currentcategory, ($layout == 'folder')); } echo '
'; @@ -967,6 +977,49 @@ function block_exaport_get_item_comp_icon($item) { . ''; } +/** + * Renders the competencies footer badge for Bootstrap card mode. + * + * @param stdClass $item + * @return string + */ +function block_exaport_get_item_comp_footer_badge($item) { + if (!block_exaport_check_competence_interaction()) { + return ''; + } + + $comps = block_exaport_get_active_comps_for_item($item); + if (!$comps) { + return ''; + } + + $titles = []; + foreach (['descriptors', 'topics'] as $key) { + if (!empty($comps[$key]) && is_array($comps[$key])) { + foreach ($comps[$key] as $comp) { + if (!empty($comp->title)) { + $titles[] = $comp->title; + } + } + } + } + + if (!$titles) { + return ''; + } + + $items = ''; + foreach ($titles as $title) { + $items .= html_writer::tag('li', format_string($title)); + } + $tooltiphtml = html_writer::tag('ul', $items, ['class' => 'tooltiplist']); + + return '' + . '' + . '' . count($titles) . '' + . ''; +} + /** * Prints the unified "Create" dropdown button (artefact + category). */ @@ -1096,133 +1149,21 @@ function block_exaport_render_item_category_badges($item) { } $badges = []; foreach ($item->flatcategories as $category) { - $badges[] = html_writer::tag('span', format_string($category->name), ['class' => 'badge badge-secondary']) . ' '; + $fullpath = format_string($category->name); + $parts = explode(' / ', $fullpath); + $shortlabel = trim(end($parts)); + $attrs = [ + 'class' => 'badge badge-secondary', + 'data-bs-toggle' => 'tooltip', + 'data-bs-placement' => 'top', + 'data-bs-title' => $fullpath, + ]; + $badges[] = html_writer::tag('span', $shortlabel, $attrs) . ' '; } if (!$badges) { return ''; } - return html_writer::div(implode('', $badges), 'mt-2'); -} - -/** - * Build a category tree keyed by parent id. - * - * @param array $categories - * @return array - */ -function block_exaport_build_categories_by_parent(array $categories) { - $categoriesbyparent = []; - foreach ($categories as $category) { - if (!isset($categoriesbyparent[$category->pid])) { - $categoriesbyparent[$category->pid] = []; - } - $categoriesbyparent[$category->pid][] = $category; - } - - return $categoriesbyparent; -} - -/** - * Load all items for flat mode and attach flatcategories to each item. - * - * @param int $userid The user whose items to load. - * @param array $categories All categories keyed by id (for path name resolution). - * @param string $sqlsort SQL ORDER BY clause. - * @param array|null $allowedcategoryids Category filter behavior: - * - null: load all categories for the viewed user in flat mode; this is all categories for your own items, or only that - * other user's own categories when viewing someone else's items. - * - empty array: return no items. - * - non-empty array: only include these category IDs and remove items with no matching categories. - * @return array The items array with ->flatcategories populated. - */ -function block_exaport_load_flat_items($userid, array $categories, $sqlsort, $allowedcategoryids = null) { - global $DB, $USER; - - if ($allowedcategoryids !== null && empty($allowedcategoryids)) { - // Keep the shared flat-mode behavior while avoiding an empty IN() SQL clause. - return []; - } - if ($allowedcategoryids !== null) { - $items = block_exaport_get_items_by_category_and_user(0, $allowedcategoryids, $sqlsort, true); - } else { - // this gets ALL the items of that user... e.g. unshared ones as well. As a teacher, this loads all the students items - // but they get filtered a few lines later with the unset() - $items = block_exaport_get_items_by_category_and_user($userid, null, $sqlsort); - } - - if (!$items) { - return []; - } - - $itemids = array_keys($items); - [$iteminsql, $iteminparams] = $DB->get_in_or_equal($itemids, SQL_PARAMS_QM); - - // Belt-and-suspenders: restrict to the viewed user's own categories, - // even though items are already scoped by userid. - $is_viewing_other_user = $allowedcategoryids === null && (int)$userid !== (int)$USER->id; - - $sql = "SELECT ic.id AS icid, ic.itemid, c.id, c.name, c.pid - FROM {block_exaportitemcate} ic - JOIN {block_exaportcate} c ON c.id = ic.cateid - WHERE ic.itemid $iteminsql"; - $params = $iteminparams; - - // Belt-and-suspenders: restrict to the viewed user's own categories, - // even though items are already scoped by userid. - if ($is_viewing_other_user) { - $sql .= " AND c.userid = ?"; - $params[] = $userid; - } - - if ($allowedcategoryids !== null) { - [$catinsql, $catinparams] = $DB->get_in_or_equal($allowedcategoryids, SQL_PARAMS_QM); - $sql .= " AND c.id $catinsql"; - $params = array_merge($params, $catinparams); - } - - $sql .= " ORDER BY c.name ASC"; - $itemcategories = $DB->get_records_sql($sql, $params); - - $categoriesbyitem = []; - foreach ($itemcategories as $itemcategory) { - $itemcategory->name = block_exaport_category_full_path_name($itemcategory->id, $categories); - if (!isset($categoriesbyitem[$itemcategory->itemid])) { - $categoriesbyitem[$itemcategory->itemid] = []; - } - $categoriesbyitem[$itemcategory->itemid][] = $itemcategory; - } - - foreach ($items as $itemid => $item) { - $item->flatcategories = $categoriesbyitem[$item->id] ?? []; - if ($allowedcategoryids !== null && !$item->flatcategories) { - unset($items[$itemid]); // this is crucial! This unsets all items that should not be displayed - } - } - - return $items; -} - -/** - * Build the full hierarchical path name for a category, e.g. "haustiere / hunde". - * - * @param int $categoryid The category id. - * @param array $categories Associative array of all categories keyed by id (must have ->name and ->pid). - * @return string The full path name with " / " separators. - */ -function block_exaport_category_full_path_name($categoryid, array $categories) { - $parts = []; - $id = $categoryid; - $visited = []; - while ($id && isset($categories[$id])) { - if (isset($visited[$id])) { - break; // Prevent infinite loop on circular references. - } - $visited[$id] = true; - $parts[] = $categories[$id]->name; - $id = $categories[$id]->pid ?? 0; - } - $parts = array_reverse($parts); - return implode(' / ', $parts); + return html_writer::div(implode('', $badges), 'eportfolio-categories'); } function block_exaport_category_path($category, $courseid = 1, $currentcategoryPathItemButtons = '') { @@ -1450,10 +1391,13 @@ function block_exaport_artefact_template_tile($item, $courseid, $type, $category * Different templates of category list. Depends on exaport settings */ function block_exaport_category_list_item($category, $courseid, $type, $currentcategory, $parentcategory = null) { + global $PAGE; $template = block_exaport_used_layout(); switch ($template) { case 'moodle_bootstrap': - return block_exaport_category_template_bootstrap_card($category, $courseid, $type, $currentcategory, $parentcategory); + return $PAGE->get_renderer('block_exaport')->render( + new \block_exaport\output\category_card($category, $courseid, $type, $currentcategory, $parentcategory) + ); break; case 'exaport_bootstrap': // may we do not need this at all? return '
TODO: !!!!!! ' . $template . ' category !!!!!!!
'; @@ -1469,11 +1413,20 @@ function block_exaport_category_list_item($category, $courseid, $type, $currentc /** * Different templates of artefact list. Depends on exaport settings */ -function block_exaport_artefact_list_item($item, $courseid, $type, $categoryid, $currentcategory) { +function block_exaport_artefact_list_item($item, $courseid, $type, $categoryid, $currentcategory, $foldermode = false) { + global $PAGE; $template = block_exaport_used_layout(); switch ($template) { case 'moodle_bootstrap': - return block_exaport_artefact_template_bootstrap_card($item, $courseid, $type, $categoryid, $currentcategory); + if ($foldermode) { + return $PAGE->get_renderer('block_exaport')->render( + new \block_exaport\output\artefact_card_folder($item, $courseid, $type, $categoryid, $currentcategory) + ); + } else { + return $PAGE->get_renderer('block_exaport')->render( + new \block_exaport\output\artefact_card_flat($item, $courseid, $type, $categoryid, $currentcategory) + ); + } break; case 'exaport_bootstrap': // may we do not need this at all? return '
TODO: !!!!!! ' . $template . ' !!!!!!!
'; @@ -1486,183 +1439,3 @@ function block_exaport_artefact_list_item($item, $courseid, $type, $categoryid, } -function block_exaport_category_template_bootstrap_card($category, $courseid, $type, $currentcategory, $parentcategory = null) { - global $CFG; - $categoryContent = ''; - - // When showing the "go up" tile: mark it as fixed (not draggable) and use - // the parent category's ID so that dropping onto it moves items there. - $tileFixedClass = $parentcategory ? 'excomdos_tile_fixed ' : ''; - $tileTargetId = $parentcategory ? $parentcategory->id : $category->id; - // Resolve the display name now so we can add data-item-name to the wrapper. - $tileName = $parentcategory ? $parentcategory->name : $category->name; - $pinnedAttr = $parentcategory ? ' data-pinned="true"' : ''; - $categoryContent .= ' -
-
-
- - '; - if ($parentcategory) { - $categoryContent .= block_exaport_get_string('category_up'); - } elseif ($currentcategory->id == -1) { - $categoryContent .= block_exaport_get_string('user'); - } else { - $categoryContent .= block_exaport_get_string('category'); - } - $categoryContent .= ''; - // edit buttons - if (!$parentcategory) { - if ($type == 'shared' || $type == 'sharedstudent') { - $categoryContent .= block_exaport_fontawesome_icon('handshake', 'regular', 1); - } else { - // Type == mine. - if (@$category->internshare && (count(exaport_get_category_shared_users($category->id)) > 0 || - count(exaport_get_category_shared_groups($category->id)) > 0 || - (isset($category->shareall) && $category->shareall == 1))) { - $categoryContent .= block_exaport_fontawesome_icon('handshake', 'regular', 1); - }; - /*if (@$category->structure_share) { - $categoryContent .= ' '; - };*/ - $categoryContent .= ' - - ' - . block_exaport_fontawesome_icon('pen-to-square', 'regular', 1) - . ' - ' - . block_exaport_fontawesome_icon('trash-can', 'regular', 1, [], [], [], '', [], [], [], ['exaport-remove-icon']) - . ' - '; - } - } - if ($parentcategory) { - $categoryThumbUrl = $parentcategory->url; - $categoryName = $parentcategory->name; - $categoryIcon = block_exaport_fontawesome_icon('folder-open', 'regular', '6', [], [], [], 'up', [], [], [], ['exaport-items-category-big']); - } else { - $categoryThumbUrl = $category->url; - $categoryName = $category->name; - if ($category->icon) { - if ($category->iconmerge) { - // icon merge (also look JS - exaport.js - block_exaport_check_fontawesome_icon_merging()): - $categoryIcon = block_exaport_fontawesome_icon('folder-open', 'regular', '6', ['icon-for-merging'], [], ['data-categoryId' => $category->id], '', [], [], [], ['exaport-items-category-big']); - $categoryIcon .= ''; - $categoryIcon .= ''; - } else { - // just picture instead of folder icon: - $categoryIcon = ''; - } - } else { - $categoryIcon = block_exaport_fontawesome_icon('folder-open', 'regular', '6', [], [], [], '', [], [], [], ['exaport-items-category-big']); - } - } - $categoryContent .= ' -
- - -
-
- '; - - return $categoryContent; -} - -; - -function block_exaport_artefact_template_bootstrap_card($item, $courseid, $type, $categoryid, $currentcategory) { - global $CFG, $USER, $DB; - - $iconTypeProps = block_exaport_item_icon_type_options($item->type); - $url = $CFG->wwwroot . '/blocks/exaport/shared_item.php?courseid=' . $courseid . '&access=portfolio/id/' . $item->userid . '&itemid=' . $item->id; - - // Build category IDs for client-side filtering. - $itemCatIds = []; - if (!empty($item->flatcategories) && is_array($item->flatcategories)) { - foreach ($item->flatcategories as $cat) { - $itemCatIds[] = (int)$cat->id; - } - } - - $itemContent = ' -
-
-
-
- ' - . block_exaport_fontawesome_icon($iconTypeProps['iconName'], $iconTypeProps['iconStyle'], 1, ['artefact_icon']) - . '' . get_string($item->type, "block_exaport") . ' -
-
'; - - if ($currentcategory->id == -1) { - // Link to export to portfolio. - $itemContent .= ' '; - } else { - if ($item->comments > 0) { - $itemContent .= ' ' . $item->comments - . block_exaport_fontawesome_icon('comment', 'regular', 1, [], [], [], '', [], [], [], []) - . ''; - } - $itemContent .= block_exaport_get_item_project_icon($item); - $itemContent .= block_exaport_get_item_comp_icon($item); - - if (in_array($type, ['mine', 'shared'])) { - $cattype = ''; - if ($type == 'shared') { - $cattype = '&cattype=shared'; - } - if ($item->userid == $USER->id) { // only for self! - $itemContent .= '' - . block_exaport_fontawesome_icon('pen-to-square', 'regular', 1) - . ''; - } - if (($type == 'mine' && $allowedit = block_exaport_item_is_editable($item->id)) // strange condition. If exacomp is not used - always allowed! - || $item->userid == $USER->id) { - if ($item->userid == $USER->id) { - $itemContent .= '' - . block_exaport_fontawesome_icon('trash-can', 'regular', 1, [], [], [], '', [], [], [], ['exaport-remove-icon']) - . ''; - } - } else if (!$allowedit = block_exaport_item_is_editable($item->id)) { - $itemContent .= 'file'; - } - if ($item->userid != $USER->id) { - $itemuser = $DB->get_record('user', ['id' => $item->userid]); - // user icon - $itemContent .= '' - . block_exaport_fontawesome_icon('circle-user', 'solid', 1) - . ''; - } - } - } - - $itemContent .= '
-
-
- - ' . $item->name . ' - -
-
- ' . $item->name . ' - ' . block_exaport_render_item_category_badges($item) . ' -
- -
-
- '; - - return $itemContent; -}