diff options
| author | Murtaza Sultani <sultani@data-quest.de> | 2025-11-06 11:37:58 +0100 |
|---|---|---|
| committer | Murtaza Sultani <sultani@data-quest.de> | 2025-11-06 11:37:58 +0100 |
| commit | 47768ce55e420f4aef1b6692c9ef71ebbd89b38c (patch) | |
| tree | 762757db81c4b6297cacfa6affd47988cbf09089 /resources | |
| parent | 51d548553e11e77f08bdd7a7700fe1ce4fbd0f79 (diff) | |
Resolve "Timeline in Forum"
Closes #5911
Merge request studip/studip!4517
Diffstat (limited to 'resources')
| -rw-r--r-- | resources/assets/stylesheets/scss/forum.scss | 133 | ||||
| -rw-r--r-- | resources/vue/apps/forum/discussions/Show.vue | 45 | ||||
| -rw-r--r-- | resources/vue/components/forum/discussions/DiscussionTimeline.vue | 298 | ||||
| -rw-r--r-- | resources/vue/components/forum/posts/Post.vue | 76 | ||||
| -rw-r--r-- | resources/vue/store/pinia/forum/ForumPost.js | 9 |
5 files changed, 415 insertions, 146 deletions
diff --git a/resources/assets/stylesheets/scss/forum.scss b/resources/assets/stylesheets/scss/forum.scss index 480a3ca..2ffb2f9 100644 --- a/resources/assets/stylesheets/scss/forum.scss +++ b/resources/assets/stylesheets/scss/forum.scss @@ -693,6 +693,95 @@ $card-max-width: 300px; } } + .discussion-timeline { + position: sticky; + top: 50px; + + &__start, + &__end { + button { + font-weight: bold; + background: none; + border: none; + padding: 0; + cursor: pointer; + } + } + + .scroll-area { + height: 300px; + display: flex; + gap: 15px; + position: relative; + cursor: pointer; + margin: 10px 5px; + + &__track { + position: relative; + background: var(--dark-gray-color-20); + width: 2px; + height: inherit; + border-radius: 4px; + overflow: hidden; + } + + &__unread { + position: absolute; + background-color: var(--red); + left: 0; + right: 0; + bottom: 0; + } + + &__new-from { + position: absolute; + width: 100%; + display: flex; + align-items: end; + + button { + background: none; + border: none; + padding: 0; + width: 100%; + text-align: left; + transition: opacity 5s; + cursor: pointer; + font-weight: bold; + margin: 0 10px; + } + } + + &__scroller { + background: white; + border: none; + padding: 0; + position: absolute; + width: 100%; + height: 50px; + cursor: ns-resize; + display: flex; + align-items: center; + gap: 10px; + } + + &__scroll-marker { + border-radius: 20px; + background: var(--color--brand-primary); + width: 6px; + height: inherit; + margin-left: -2px; + } + + &__info { + font-weight: bold; + text-align: left; + flex: 1; + color: var(--color--brand-primary); + } + } + } + .posts-container { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); @@ -956,46 +1045,6 @@ $card-max-width: 300px; } } - .timeline-container { - position: sticky; - top: 50px; - } - - .discussion-timeline-table { - width: 100%; - height: 350px; - - tr td:first-child { - width: 6px; - background-color: var(--color--tile-title-background); - } - - tr:first-child td:first-child { - background-color: var(--color--highlight); - } - - tr td:nth-child(2) { - padding: 0 10px; - } - - time, p { - color: var(--color--font-secondary); - margin: 0; - } - - time { - font-weight: 600; - } - - p { - font-size: small; - } - - td.bg-new-activity { - background-color: $red !important; - } - } - .drag-handle { display: inline-block; width: 6px; @@ -1581,7 +1630,7 @@ $card-max-width: 300px; grid-template-columns: 1fr; } - .timeline-container { + .discussion-timeline { display: none; } @@ -1724,7 +1773,7 @@ $card-max-width: 300px; } .fullscreen-mode .forum { - .timeline-container { + .discussion-timeline { top: 120px; } diff --git a/resources/vue/apps/forum/discussions/Show.vue b/resources/vue/apps/forum/discussions/Show.vue index 08edb6b..4fa4fad 100644 --- a/resources/vue/apps/forum/discussions/Show.vue +++ b/resources/vue/apps/forum/discussions/Show.vue @@ -1,19 +1,20 @@ <script setup> import {onMounted, computed, ref} from "vue"; import ForumApp from "@/vue/components/forum/ForumApp.vue"; -import {useForumPost} from "../../../store/pinia/forum/ForumPost"; -import {$gettext} from "../../../../assets/javascripts/lib/gettext"; +import {useForumPost} from "@/vue/store/pinia/forum/ForumPost"; +import {$gettext} from "@/assets/javascripts/lib/gettext"; import Post from "@/vue/components/forum/posts/Post.vue"; import PostCreateForm from "@/vue/components/forum/posts/PostCreateForm.vue"; import Loader from "@/vue/components/forum/Loader.vue"; -import {useForumConfig} from "../../../store/pinia/forum/ForumConfig"; -import StudipIcon from "../../../components/StudipIcon.vue"; -import StudipDateTime from "../../../components/StudipDateTime.vue"; +import {useForumConfig} from "@/vue/store/pinia/forum/ForumConfig"; +import StudipIcon from "@/vue/components/StudipIcon.vue"; +import StudipDateTime from "@/vue/components/StudipDateTime.vue"; import SubscriptionDropdown from "@/vue/components/forum/SubscriptionDropdown.vue"; import {highlightText, removeHighlight} from "@/vue/components/forum/helpers"; import {getSearchURL, getTopicURL, getDiscussionIndexURL} from "@/vue/components/forum/helpers/urls"; -import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils"; -import DiscussionFooter from "../../../components/forum/discussions/DiscussionFooter.vue"; +import {deserializeJSONAPIResponse} from "@/assets/javascripts/lib/jsonapiUtils"; +import DiscussionFooter from "@/vue/components/forum/discussions/DiscussionFooter.vue"; +import DiscussionTimeline from "@/vue/components/forum/discussions/DiscussionTimeline.vue"; const forumConfig = useForumConfig(); const forumPostStore = useForumPost(); @@ -114,6 +115,14 @@ const fetchPostings = async () => { } }; +const jumpTo = targetElement => { + if (!targetElement) { + return; + } + + targetElement.scrollIntoView({ behavior: 'instant' }); + requestAnimationFrame(() => STUDIP.eventBus.emit('forum:jumpToPost', targetElement.dataset?.index || 0)); +}; onMounted(async () => { await fetchPostings(); @@ -123,18 +132,22 @@ onMounted(async () => { if (urlHash === 'new-post') { postCreateForm.value = true; } - document.getElementById(urlHash)?.scrollIntoView(); + jumpTo(document.getElementById(urlHash)) } else if (props.read_index < posts.value.length) { - document.querySelectorAll(".post")[props.read_index].scrollIntoView(); + if (props.read_index === 0) { + jumpTo(document.getElementById('discussion_start')); + } else { + jumpTo(document.querySelector(`[data-index='${props.read_index}']`)); + } } if (props.search_keyword !== "") { highlightText(props.search_keyword, '.post-content'); - document.querySelector('.post-content mark')?.scrollIntoView(); + jumpTo(document.querySelector('.post-content mark')) // remove highlights - document.getElementById("discussion_start").addEventListener("click", function() { + document.getElementById('discussion_start').addEventListener('click', function() { removeHighlight('.post-content mark'); }); } @@ -207,7 +220,7 @@ onMounted(async () => { </header> <div class="discussion"> <template v-if="posts[0]"> - <Post :post="posts[0]" :auth_user="auth_user" :discussion="discussion" :is_unread="read_index === 0" /> + <Post :post="posts[0]" :auth_user="auth_user" :discussion="discussion" :readIndex="read_index" /> </template> <div v-else class="discussion__body"> <Loader v-if="isLoading" /> @@ -230,7 +243,8 @@ onMounted(async () => { :post="post" :auth_user="auth_user" :discussion="discussion" - :is_unread="read_index < index + 2" + :index="index + 1" + :readIndex="read_index" /> <hr v-if="index < posts.length - 2" class="divider m-0" /> </template> @@ -253,6 +267,10 @@ onMounted(async () => { @created="addPost" /> </div> + + <template #sidebar> + <DiscussionTimeline :discussion="discussion" :posts="posts" :readIndex="read_index" /> + </template> </ForumApp> </template> @@ -267,5 +285,6 @@ onMounted(async () => { } html { scroll-behavior: smooth; + scroll-padding-top: 50px !important; } </style> diff --git a/resources/vue/components/forum/discussions/DiscussionTimeline.vue b/resources/vue/components/forum/discussions/DiscussionTimeline.vue index c394d01..f54aa61 100644 --- a/resources/vue/components/forum/discussions/DiscussionTimeline.vue +++ b/resources/vue/components/forum/discussions/DiscussionTimeline.vue @@ -1,88 +1,252 @@ <script setup> -import {computed} from "vue"; -import StudipDateTime from "@/vue/components/StudipDateTime.vue"; +import {computed, onMounted, onUnmounted, ref} from 'vue'; +import StudipDateTime from '@/vue/components/StudipDateTime.vue'; +import {useForumPost} from '@/vue/store/pinia/forum/ForumPost'; +import {useForumConfig} from '@/vue/store/pinia/forum/ForumConfig'; +import {$gettext} from '@/assets/javascripts/lib/gettext'; +const forumConfig = useForumConfig(); +const forumPostStore = useForumPost(); const props = defineProps({ + discussion: { + type: Object, + required: true, + }, posts: { type: Array, required: true, }, - read_index: { + readIndex: { type: Number, - required: true, default: 0 - }, - discussion: { - type: Object, - required: true, } }); -const readPosts = computed(() => props.posts.slice(0, props.read_index)); +const scrollerTop = ref(0); +const isDragging = ref(false); +const unreadScrollPosition = ref(-1); + +const othersPosts = computed(() => props.posts.filter(({ author }) => author?.id !== STUDIP.USER_ID)); +const currentPostIndex = computed(() => forumPostStore.currentPostIndex); +const currentPostDate = computed(() => { + if (currentPostIndex.value < props.posts.length) { + const date = new Date(props.posts[currentPostIndex.value].mkdate); + return date.toLocaleString(String.locale, { month: 'long', year: 'numeric' }); + } + + return null; +}); +const isNewFrom = computed(() => { + return unreadScrollPosition.value >= 0 + && props.readIndex < othersPosts.value.length + && !forumConfig.allowGuestAccess +}); + +const findPostAtScroll = y => { + const postElements = document.querySelectorAll('.post'); + for (const postElement of postElements) { + const postScrollPosition = postElement.getBoundingClientRect().top + window.scrollY; + + if (postScrollPosition > y) { + return postElement; + } + } -const unreadPosts = computed(() => props.posts.slice(props.read_index)); + return null; +} + +const jumpToPost = (targetPost, index = 0) => { + if (!targetPost) { + targetPost = document.querySelector(`[data-index='${index}']`); + } -const readPostsPercentage = computed(() => { - if (props.posts.length === 0) { - return 100; + if (parseInt(targetPost?.dataset.index) === 0) { + document.getElementById('discussion_start').scrollIntoView({ behavior: 'instant' }); + return; } - return parseFloat((props.posts.length - unreadPosts.value.length) * 100 / props.posts.length); + targetPost?.scrollIntoView({ behavior: 'instant' }); +} + +const jumpTo = e => { + const contentContainer = document.documentElement; + const trackRect = e.currentTarget.getBoundingClientRect(); + const clickY = e.clientY - trackRect.top; + const percent = Math.min(Math.max(clickY / trackRect.height, 0), 1); + + const scrollPosition = percent * (contentContainer.scrollHeight - contentContainer.clientHeight); + const targetPost = findPostAtScroll(scrollPosition); + + if (targetPost) { + jumpToPost(targetPost); + updateScroller(scrollPosition); + } else { + contentContainer.scrollTop = scrollPosition; + } +} + +const startDrag = e => { + isDragging.value = true; + let scrollPosition = 0; + let targetPost = null; + + const contentContainer = document.documentElement; + const rectScrollArea = document.getElementById('scroll-area').getBoundingClientRect(); + const scrollerRect = document.getElementById('scroller').getBoundingClientRect(); + + const offsetY = e.clientY - (scrollerRect.top + scrollerRect.height / 2); + + const onDrag = e2 => { + const y = e2.clientY - rectScrollArea.top - offsetY; + const percent = Math.min(Math.max(y / rectScrollArea.height, 0), 1); + + scrollerTop.value = percent * 100; + scrollPosition = percent * (contentContainer.scrollHeight - contentContainer.clientHeight); + targetPost = findPostAtScroll(scrollPosition); + forumPostStore.updateCurrentPostIndex(parseInt(targetPost?.dataset.index ?? 0)) + updateScroller(scrollPosition); + }; + + const onDrop = () => { + if (targetPost) { + jumpToPost(targetPost); + } else { + contentContainer.scrollTop = scrollPosition; + } + + isDragging.value = false; + + document.removeEventListener('mousemove', onDrag); + document.removeEventListener('mouseup', onDrop); + }; + + document.addEventListener('mousemove', onDrag); + document.addEventListener('mouseup', onDrop); +} + +const updateScroller = (scrollPosition = -1, ignoreOffset = 0) => { + const contentContainer = document.documentElement; + scrollPosition = scrollPosition > -1 ? scrollPosition : contentContainer.scrollTop; + const range = Math.max(1, contentContainer.scrollHeight - contentContainer.clientHeight - ignoreOffset); + scrollerTop.value = Math.min(100, Math.max(0, scrollPosition - ignoreOffset) / range * 100); + + if (scrollerTop.value === 0) { + forumPostStore.updateCurrentPostIndex(0); + } +} + +const handleScroll = () => { + if (!isDragging.value) { + updateScroller(window.scrollY, 200); + } +}; + +const updateUnreadScrollPosition = () => { + if (props.readIndex === 0) { + unreadScrollPosition.value = 0; + return; + } + + const firstUnreadPost = document.querySelector(`[data-index='${props.readIndex}']`); + if (!firstUnreadPost) { + return; + } + + const contentContainer = document.documentElement; + const elementTop = firstUnreadPost.getBoundingClientRect().top + window.scrollY - 200; + const scrollableHeight = contentContainer.scrollHeight - contentContainer.clientHeight; + unreadScrollPosition.value = Math.min(Math.max((elementTop / scrollableHeight) * 100, 0), 90); +}; + +onMounted(() => { + window.addEventListener('scroll', handleScroll); + STUDIP.eventBus.on('forum:jumpToPost', updateUnreadScrollPosition); +}); + +onUnmounted(() => { + window.removeEventListener('scroll', handleScroll); + STUDIP.eventBus.off('forum:jumpToPost', updateUnreadScrollPosition); }); </script> <template> - <table class="discussion-timeline-table" cellspacing="0"> - <tbody> - <tr> - <td></td> - <td> - <a href="#discussion_start"> - <StudipDateTime :iso="discussion.mkdate" :relative="true" /> - <p>1/{{ posts.length }}</p> - </a> - </td> - </tr> - <tr v-if="readPostsPercentage > 0" :style="{height: readPostsPercentage+'%' }"> - <td></td> - <td></td> - </tr> - <template v-if="unreadPosts.length > 0"> - <tr> - <td class="bg-new-activity"></td> - <td> - <a :href="'#post_'+unreadPosts[0].id"> - <StudipDateTime :iso="unreadPosts[0].mkdate" :relative="true" /> - <p>{{ readPosts.length + 1 }}/{{ posts.length }} - {{ $gettext('neu ab hier') }}</p> - </a> - </td> - </tr> - <tr :style="{height: (100 - readPostsPercentage)+'%' }"> - <td class="bg-new-activity"></td> - <td></td> - </tr> - </template> - <tr v-if="posts.length > 0"> - <td class="bg-new-activity"></td> - <td> - <a :href="'#post_'+posts[posts.length -1].id"> - <StudipDateTime :iso="posts[posts.length -1].mkdate" :relative="true" /> - <p>{{ posts.length }}/{{ posts.length }}</p> - </a> - </td> - </tr> - <tr v-else> - <td></td> - <td> - <StudipDateTime :iso="discussion.mkdate" :relative="true" /> - <p>{{ $gettext('Keine Beitrag bis hier') }}</p> - </td> - </tr> - </tbody> - </table> + <div class="discussion-timeline"> + <div class="discussion-timeline__start"> + <button + type="button" + class="button-base" + @click="jumpToPost(null, 0)" + :title="$gettext('Zum ersten Beitrag')" + > + <StudipDateTime :iso="discussion.mkdate" :relative="true" /> + </button> + </div> + <div id="scroll-area" class="scroll-area" @click="jumpTo"> + <div class="scroll-area__track"> + <Transition name="fade"> + <div + v-if="isNewFrom" + class="scroll-area__unread" + :style="{ + top: `${unreadScrollPosition}%` + }" + > + </div> + </Transition> + </div> + <Transition name="fade"> + <div + v-if="isNewFrom && currentPostIndex !== readIndex" + class="scroll-area__new-from" + :style="{ + top: `${unreadScrollPosition}%` + }"> + <button + type="button" + class="button-base" + @click.stop="jumpToPost(null, readIndex)" + :title="$gettext('Zum ersten ungelesenen Beitrag')" + > + {{ $gettext('Neu ab hier') }} + </button> + </div> + </Transition> + <button + type="button" + id="scroller" + class="scroll-area__scroller" + :style="{ + top: `${scrollerTop}%`, + transform: `translateY(-${scrollerTop}%)`, + cursor: posts.length > 1 ? 'ns-resize' : 'not-allowed' + }" + @mousedown.prevent="startDrag" + @click.stop + > + <span class="scroll-area__scroll-marker"></span> + <span class="scroll-area__info"> + {{ currentPostIndex + 1 }}/{{ posts.length }} <br /> + <time v-if="currentPostDate" :datetime="currentPostDate"> + {{ currentPostDate }} + </time> + <Transition name="fade"> + <span v-if="isNewFrom && currentPostIndex === readIndex"> + — {{ $gettext('Neu ab hier') }} + </span> + </Transition> + </span> + </button> + </div> + <div class="discussion-timeline__end"> + <button + type="button" + class="button-base" + @click="jumpToPost(null, posts.length -1)" + :title="$gettext('Zum letzten Beitrag')" + > + <StudipDateTime v-if="posts.length > 0" :iso="posts[posts.length -1].mkdate" :relative="true" /> + <StudipDateTime v-else :iso="discussion.mkdate" :relative="true" /> + </button> + </div> + </div> </template> -<style> -html { - scroll-behavior: smooth; -} -</style> diff --git a/resources/vue/components/forum/posts/Post.vue b/resources/vue/components/forum/posts/Post.vue index ff935e7..1f8a5a7 100644 --- a/resources/vue/components/forum/posts/Post.vue +++ b/resources/vue/components/forum/posts/Post.vue @@ -1,21 +1,21 @@ <script setup> -import {computed, ref, useTemplateRef} from "vue"; -import PostEditForm from "./PostEditForm.vue"; -import PostCreateForm from "./PostCreateForm.vue"; -import PostContent from "@/vue/components/forum/posts/PostContent.vue"; -import UserAvatarDropdown from "@/vue/components/avatar/UserAvatarDropdown.vue"; -import PostReactions from "./PostReactions.vue"; -import {useForumPost} from "../../../store/pinia/forum/ForumPost"; -import {getDiscussionURL} from "@/vue/components/forum/helpers/urls"; -import StudipDateTime from "@/vue/components/StudipDateTime.vue"; -import StudipIcon from "@/vue/components/StudipIcon.vue"; -import {$gettext} from "@/assets/javascripts/lib/gettext"; -import LinksPreview from "@/vue/components/LinksPreview.vue"; -import {userProfileURL} from "../helpers/urls"; -import {useForumConfig} from "../../../store/pinia/forum/ForumConfig"; +import {computed, onBeforeUnmount, onMounted, ref, useTemplateRef} from 'vue'; +import PostEditForm from './PostEditForm.vue'; +import PostCreateForm from './PostCreateForm.vue'; +import PostContent from '@/vue/components/forum/posts/PostContent.vue'; +import PostReactions from './PostReactions.vue'; +import {getDiscussionURL} from '@/vue/components/forum/helpers/urls'; +import StudipDateTime from '@/vue/components/StudipDateTime.vue'; +import StudipIcon from '@/vue/components/StudipIcon.vue'; +import {$gettext} from '@/assets/javascripts/lib/gettext'; +import LinksPreview from '@/vue/components/LinksPreview.vue'; +import UserAvatarDropdown from '@/vue/components/avatar/UserAvatarDropdown.vue'; +import {userProfileURL} from '../helpers/urls'; +import {useForumPost} from '@/vue/store/pinia/forum/ForumPost'; +import {useForumConfig} from '@/vue/store/pinia/forum/ForumConfig'; const forumConfig = useForumConfig(); -const forumDiscussionPost = useForumPost(); +const forumPostStore = useForumPost(); const props = defineProps({ discussion: { type: Object, @@ -29,12 +29,17 @@ const props = defineProps({ type: Object, required: true }, - is_unread: { - type: Boolean, - default: false + index: { + type: Number, + default: 0 + }, + readIndex: { + type: Number, + default: 0 } }); +const postRef = useTemplateRef('postRef'); const postContentRef = useTemplateRef('postContent'); const userAvatarContainerRef = useTemplateRef('userAvatarContainer'); @@ -42,7 +47,7 @@ const selectedText = ref(''); const showPostEditForm = ref(false); const showPostCreateForm = ref(false); -const isUnread = computed(() => (!props.post.author && props.is_unread) || (props.is_unread && props.post.author.id !== STUDIP.USER_ID)) +const isUnread = computed(() => (!props.post.author && props.index >= props.readIndex) || (props.index >= props.readIndex && props.post.author.id !== STUDIP.USER_ID)) const canEditPost = computed(() => forumConfig.isTutor || (props.post.author?.id === STUDIP.USER_ID && !props.discussion.closed_at)); const canDeletePost = computed(() => canEditPost.value); const copyToClipboard = () => { @@ -71,13 +76,14 @@ const deletePost = async () => { async () => { try { await STUDIP.jsonapi.withPromises().DELETE(`forum-postings/${props.post.id}`); - forumDiscussionPost.removePost(props.post.id); + forumPostStore.removePost(props.post.id); STUDIP.Report.success($gettext('Der Beitrag wurde gelöscht.')); } catch (error) { STUDIP.Report.error(error); } }, - STUDIP.Dialog.close()); + STUDIP.Dialog.close() + ); } const addPost = () => { @@ -113,15 +119,39 @@ const forwardPost = post => { const removePostHighlight = id => { const element = document.getElementById(id); if (!element) { - console.error("Element not found!"); + console.error('Element not found!'); return; } element.classList.remove('--highlight'); } + +let postObserver = null; + +onMounted(() => { + postObserver = new IntersectionObserver( + entries => entries.forEach(e => { + if (e.isIntersecting) { + forumPostStore.updateCurrentPostIndex(props.index); + } + }),{ + rootMargin: `-110px 0px -${document.documentElement.clientHeight - 120}px 0px` + } + ); + + postObserver.observe(postRef.value); +}); + +onBeforeUnmount(() => postObserver.disconnect()); </script> <template> - <div :id="`post_${post.id}`" class="post" @click="removePostHighlight(`post_${post.id}`)"> + <div + ref="postRef" + :id="`post_${post.id}`" + class="post" + :data-index="index" + @click="removePostHighlight(`post_${post.id}`)" + > <div v-if="!forumConfig.allowGuestAccess && isUnread" class="post__unread"> </div> <div class="post__body"> diff --git a/resources/vue/store/pinia/forum/ForumPost.js b/resources/vue/store/pinia/forum/ForumPost.js index 3990ccc..a5fa749 100644 --- a/resources/vue/store/pinia/forum/ForumPost.js +++ b/resources/vue/store/pinia/forum/ForumPost.js @@ -5,6 +5,7 @@ export const useForumPost = defineStore( () => { const posts = ref([]); + const currentPostIndex = ref(0); function initPosts(newPosts) { posts.value = newPosts; @@ -40,14 +41,20 @@ export const useForumPost = defineStore( } } + function updateCurrentPostIndex(index) { + currentPostIndex.value = index; + } + return { posts, + currentPostIndex, initPosts, addPost, updatePost, removePost, addPostReaction, - removePostReaction + removePostReaction, + updateCurrentPostIndex } } ) |
