mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
880 lines
25 KiB
Vue
880 lines
25 KiB
Vue
<template>
|
|
<div class="thread-view">
|
|
<!-- Toolbar -->
|
|
<AppToolbar>
|
|
<template #left>
|
|
<NcButton @click="goBack">
|
|
<template #icon>
|
|
<ArrowLeftIcon :size="20" />
|
|
</template>
|
|
{{ thread?.categoryName ? strings.backToCategory(thread.categoryName) : strings.back }}
|
|
</NcButton>
|
|
</template>
|
|
|
|
<template #right>
|
|
<!-- Subscription toggle switch -->
|
|
<NcCheckboxRadioSwitch
|
|
v-if="!loading && thread"
|
|
v-model="thread.isSubscribed"
|
|
@update:model-value="handleToggleSubscription"
|
|
type="switch"
|
|
>
|
|
<span class="icon-label">
|
|
<BellIcon :size="20" />
|
|
{{ thread.isSubscribed ? strings.subscribed : strings.subscribe }}
|
|
</span>
|
|
</NcCheckboxRadioSwitch>
|
|
|
|
<NcButton
|
|
@click="refresh"
|
|
:disabled="loading"
|
|
:aria-label="strings.refresh"
|
|
:title="strings.refresh"
|
|
>
|
|
<template #icon>
|
|
<RefreshIcon :size="20" />
|
|
</template>
|
|
</NcButton>
|
|
|
|
<!-- Moderation buttons (only visible to moderators) -->
|
|
<template v-if="canModerate && !loading">
|
|
<NcButton
|
|
@click="handleToggleLock"
|
|
:aria-label="thread?.isLocked ? strings.unlockThread : strings.lockThread"
|
|
:title="thread?.isLocked ? strings.unlockThread : strings.lockThread"
|
|
>
|
|
<template #icon>
|
|
<LockOpenIcon v-if="thread?.isLocked" :size="20" />
|
|
<LockIcon v-else :size="20" />
|
|
</template>
|
|
</NcButton>
|
|
|
|
<NcButton
|
|
@click="handleTogglePin"
|
|
:aria-label="thread?.isPinned ? strings.unpinThread : strings.pinThread"
|
|
:title="thread?.isPinned ? strings.unpinThread : strings.pinThread"
|
|
>
|
|
<template #icon>
|
|
<PinOffIcon v-if="thread?.isPinned" :size="20" />
|
|
<PinIcon v-else :size="20" />
|
|
</template>
|
|
</NcButton>
|
|
</template>
|
|
|
|
<NcButton
|
|
@click="replyToThread"
|
|
:disabled="loading || (thread?.isLocked && !canModerate)"
|
|
variant="primary"
|
|
>
|
|
<template #icon>
|
|
<ReplyIcon :size="20" />
|
|
</template>
|
|
{{ strings.reply }}
|
|
</NcButton>
|
|
</template>
|
|
</AppToolbar>
|
|
|
|
<!-- Loading state -->
|
|
<div class="center mt-16" v-if="loading">
|
|
<NcLoadingIcon :size="32" />
|
|
<span class="muted ml-8">{{ strings.loading }}</span>
|
|
</div>
|
|
|
|
<!-- Error state -->
|
|
<NcEmptyContent
|
|
v-else-if="error"
|
|
:title="strings.errorTitle"
|
|
:description="error"
|
|
class="mt-16"
|
|
>
|
|
<template #action>
|
|
<NcButton @click="refresh">
|
|
<template #icon>
|
|
<RefreshIcon :size="20" />
|
|
</template>
|
|
{{ strings.retry }}
|
|
</NcButton>
|
|
</template>
|
|
</NcEmptyContent>
|
|
|
|
<!-- Thread Header -->
|
|
<div v-else-if="thread" class="thread-header mt-16">
|
|
<div class="thread-title-section">
|
|
<h2 class="thread-title">
|
|
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
|
|
<PinIcon :size="20" />
|
|
</span>
|
|
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
|
|
<LockIcon :size="20" />
|
|
</span>
|
|
{{ thread.title }}
|
|
</h2>
|
|
<div class="thread-meta">
|
|
<span class="meta-item">
|
|
<span class="meta-label">{{ strings.by }}</span>
|
|
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
|
|
{{ thread.authorDisplayName || thread.authorId }}
|
|
</span>
|
|
</span>
|
|
<span class="meta-divider">·</span>
|
|
<span class="meta-item">
|
|
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
|
|
</span>
|
|
<span class="meta-divider">·</span>
|
|
<span class="meta-item">
|
|
<span class="stat-icon">
|
|
<EyeIcon :size="16" />
|
|
</span>
|
|
<span class="stat-label">{{ strings.views(thread.viewCount) }}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Posts list -->
|
|
<section v-if="!loading && !error && posts.length > 0" class="mt-16">
|
|
<div class="posts-list">
|
|
<PostCard
|
|
v-for="(post, index) in posts"
|
|
:key="post.id"
|
|
:ref="(el) => setPostCardRef(el, post.id)"
|
|
:post="post"
|
|
:is-first-post="index === 0"
|
|
:is-unread="isPostUnread(post)"
|
|
@reply="handleReply"
|
|
@update="handleUpdate"
|
|
@delete="handleDelete"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Pagination info -->
|
|
<div v-if="posts.length >= limit" class="pagination-info mt-16">
|
|
<p class="muted">{{ strings.showingPosts(posts.length) }}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Empty posts state (thread exists but no posts) -->
|
|
<NcEmptyContent
|
|
v-else-if="!loading && !error && thread && posts.length === 0"
|
|
:title="strings.emptyPostsTitle"
|
|
:description="strings.emptyPostsDesc"
|
|
class="mt-16"
|
|
>
|
|
<template #action>
|
|
<NcButton @click="replyToThread" variant="primary">
|
|
<template #icon>
|
|
<ReplyIcon :size="20" />
|
|
</template>
|
|
{{ strings.reply }}
|
|
</NcButton>
|
|
</template>
|
|
</NcEmptyContent>
|
|
|
|
<!-- Locked message (only shown to non-moderators) -->
|
|
<div
|
|
v-if="!loading && !error && thread && thread.isLocked && !canModerate"
|
|
class="locked-message mt-16"
|
|
>
|
|
<NcEmptyContent :title="strings.locked" :description="strings.lockedMessage">
|
|
<template #icon>
|
|
<LockIcon :size="64" />
|
|
</template>
|
|
</NcEmptyContent>
|
|
</div>
|
|
|
|
<!-- Reply form (moderators can reply even when locked) -->
|
|
<PostReplyForm
|
|
v-if="!loading && !error && thread && (!thread.isLocked || canModerate)"
|
|
ref="replyForm"
|
|
@submit="handleSubmitReply"
|
|
@cancel="handleCancelReply"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent } from 'vue'
|
|
import NcButton from '@nextcloud/vue/components/NcButton'
|
|
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
|
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
|
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
|
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
|
import AppToolbar from '@/components/AppToolbar.vue'
|
|
import PostCard from '@/components/PostCard.vue'
|
|
import PostReplyForm from '@/components/PostReplyForm.vue'
|
|
import PinIcon from '@icons/Pin.vue'
|
|
import PinOffIcon from '@icons/PinOff.vue'
|
|
import LockIcon from '@icons/Lock.vue'
|
|
import LockOpenIcon from '@icons/LockOpen.vue'
|
|
import EyeIcon from '@icons/Eye.vue'
|
|
import BellIcon from '@icons/Bell.vue'
|
|
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
|
import RefreshIcon from '@icons/Refresh.vue'
|
|
import ReplyIcon from '@icons/Reply.vue'
|
|
import type { Post } from '@/types'
|
|
import { ocs } from '@/axios'
|
|
import { t, n } from '@nextcloud/l10n'
|
|
import { showError, showSuccess } from '@nextcloud/dialogs'
|
|
import { useCurrentThread } from '@/composables/useCurrentThread'
|
|
import { usePermissions } from '@/composables/usePermissions'
|
|
|
|
export default defineComponent({
|
|
name: 'ThreadView',
|
|
components: {
|
|
NcButton,
|
|
NcCheckboxRadioSwitch,
|
|
NcEmptyContent,
|
|
NcLoadingIcon,
|
|
NcDateTime,
|
|
AppToolbar,
|
|
PostCard,
|
|
PostReplyForm,
|
|
PinIcon,
|
|
PinOffIcon,
|
|
LockIcon,
|
|
LockOpenIcon,
|
|
EyeIcon,
|
|
BellIcon,
|
|
ArrowLeftIcon,
|
|
RefreshIcon,
|
|
ReplyIcon,
|
|
},
|
|
setup() {
|
|
const { currentThread: thread, fetchThread } = useCurrentThread()
|
|
const { checkCategoryPermission } = usePermissions()
|
|
|
|
return {
|
|
thread,
|
|
fetchThread,
|
|
checkCategoryPermission,
|
|
}
|
|
},
|
|
data() {
|
|
return {
|
|
loading: false,
|
|
posts: [] as Post[],
|
|
lastReadPostId: null as number | null,
|
|
error: null as string | null,
|
|
limit: 50,
|
|
offset: 0,
|
|
postCardRefs: new Map<number, any>(),
|
|
canModerate: false,
|
|
|
|
strings: {
|
|
back: t('forum', 'Back'),
|
|
backToCategory: (categoryName: string) =>
|
|
t('forum', 'Back to {category}', { category: categoryName }),
|
|
refresh: t('forum', 'Refresh'),
|
|
reply: t('forum', 'Reply'),
|
|
loading: t('forum', 'Loading…'),
|
|
errorTitle: t('forum', 'Error loading thread'),
|
|
emptyPostsTitle: t('forum', 'No posts yet'),
|
|
emptyPostsDesc: t('forum', 'Be the first to post in this thread.'),
|
|
retry: t('forum', 'Retry'),
|
|
by: t('forum', 'by'),
|
|
views: (count: number) => n('forum', '%n view', '%n views', count),
|
|
pinned: t('forum', 'Pinned thread'),
|
|
locked: t('forum', 'Locked thread'),
|
|
lockedMessage: t('forum', 'This thread is locked. Only moderators can post replies.'),
|
|
showingPosts: (count: number) => n('forum', 'Showing %n post', 'Showing %n posts', count),
|
|
lockThread: t('forum', 'Lock thread'),
|
|
unlockThread: t('forum', 'Unlock thread'),
|
|
pinThread: t('forum', 'Pin thread'),
|
|
unpinThread: t('forum', 'Unpin thread'),
|
|
threadLocked: t('forum', 'Thread locked'),
|
|
threadUnlocked: t('forum', 'Thread unlocked'),
|
|
threadPinned: t('forum', 'Thread pinned'),
|
|
threadUnpinned: t('forum', 'Thread unpinned'),
|
|
subscribe: t('forum', 'Subscribe to thread'),
|
|
subscribed: t('forum', 'Subscribed'),
|
|
threadSubscribed: t('forum', 'Subscribed to thread'),
|
|
threadUnsubscribed: t('forum', 'Unsubscribed from thread'),
|
|
},
|
|
}
|
|
},
|
|
computed: {
|
|
threadId(): number | null {
|
|
return this.$route.params.id ? parseInt(this.$route.params.id as string) : null
|
|
},
|
|
threadSlug(): string | null {
|
|
return (this.$route.params.slug as string) || null
|
|
},
|
|
},
|
|
watch: {
|
|
'$route.hash'(newHash: string) {
|
|
// When hash changes within the same thread, scroll to the post
|
|
if (newHash && newHash.startsWith('#post-') && this.posts.length > 0) {
|
|
this.$nextTick(() => {
|
|
this.scrollToPostFromHash()
|
|
})
|
|
}
|
|
},
|
|
},
|
|
created() {
|
|
this.refresh()
|
|
},
|
|
methods: {
|
|
async refresh(): Promise<void> {
|
|
try {
|
|
this.loading = true
|
|
this.error = null
|
|
|
|
// Fetch thread details using the composable
|
|
// Increment view count on initial load, but not on manual refresh
|
|
const incrementView = !this.thread
|
|
let threadData
|
|
if (this.threadSlug) {
|
|
threadData = await this.fetchThread(this.threadSlug, true, incrementView)
|
|
} else if (this.threadId) {
|
|
threadData = await this.fetchThread(this.threadId, false, incrementView)
|
|
} else {
|
|
throw new Error(t('forum', 'No thread ID or slug provided'))
|
|
}
|
|
|
|
// Fetch posts
|
|
if (threadData) {
|
|
await this.fetchPosts()
|
|
// Check moderation permission
|
|
await this.checkModerationPermission()
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to refresh', e)
|
|
this.error = (e as Error).message || t('forum', 'An unexpected error occurred')
|
|
} finally {
|
|
this.loading = false
|
|
}
|
|
},
|
|
|
|
async checkModerationPermission(): Promise<void> {
|
|
if (this.thread?.categoryId) {
|
|
this.canModerate = await this.checkCategoryPermission(this.thread.categoryId, 'canModerate')
|
|
}
|
|
},
|
|
|
|
async fetchPosts(): Promise<void> {
|
|
try {
|
|
// Fetch existing read marker before loading posts
|
|
await this.fetchReadMarker()
|
|
|
|
const resp = await ocs.get<Post[]>(`/threads/${this.thread!.id}/posts`, {
|
|
params: {
|
|
limit: this.limit,
|
|
offset: this.offset,
|
|
},
|
|
})
|
|
this.posts = resp.data || []
|
|
|
|
// Mark thread as read up to the last post in the current view
|
|
if (this.posts.length > 0) {
|
|
await this.markAsRead()
|
|
}
|
|
|
|
// Scroll to post if hash is present in URL
|
|
await this.$nextTick()
|
|
this.scrollToPostFromHash()
|
|
} catch (e) {
|
|
console.error('Failed to fetch posts', e)
|
|
throw new Error(t('forum', 'Failed to load posts'))
|
|
}
|
|
},
|
|
|
|
async fetchReadMarker(): Promise<void> {
|
|
try {
|
|
if (!this.thread) {
|
|
return
|
|
}
|
|
|
|
const resp = await ocs.get<{ threadId: number; lastReadPostId: number; readAt: number }>(
|
|
`/threads/${this.thread.id}/read-marker`,
|
|
)
|
|
this.lastReadPostId = resp.data?.lastReadPostId || null
|
|
} catch (e) {
|
|
// Not found or error - treat as no read marker
|
|
this.lastReadPostId = null
|
|
console.debug('No read marker found', e)
|
|
}
|
|
},
|
|
|
|
isPostUnread(post: Post): boolean {
|
|
if (this.lastReadPostId === null) {
|
|
// No read marker means all posts are unread
|
|
return true
|
|
}
|
|
// Post is unread if its ID is greater than last read post ID
|
|
return post.id > this.lastReadPostId
|
|
},
|
|
|
|
async markAsRead(): Promise<void> {
|
|
try {
|
|
// Get the last post ID from the current view
|
|
const lastPost = this.posts[this.posts.length - 1]
|
|
if (!lastPost || !this.thread) {
|
|
return
|
|
}
|
|
|
|
// Send request to mark thread as read
|
|
await ocs.post('/read-markers', {
|
|
threadId: this.thread.id,
|
|
lastReadPostId: lastPost.id,
|
|
})
|
|
} catch (e) {
|
|
// Silently fail - marking as read is not critical
|
|
console.debug('Failed to mark thread as read', e)
|
|
}
|
|
},
|
|
|
|
handleReply(post: Post): void {
|
|
const replyForm = this.$refs.replyForm as any
|
|
if (!replyForm) {
|
|
return
|
|
}
|
|
|
|
// Set the quoted content in the reply form
|
|
if (replyForm && typeof replyForm.setQuotedContent === 'function') {
|
|
replyForm.setQuotedContent(post.contentRaw)
|
|
}
|
|
|
|
// Scroll to the reply form with smooth behavior
|
|
const element = replyForm.$el as HTMLElement
|
|
if (element) {
|
|
element.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'nearest',
|
|
inline: 'nearest',
|
|
})
|
|
}
|
|
|
|
// Wait for scroll animation to complete before focusing
|
|
setTimeout(() => {
|
|
if (replyForm && typeof replyForm.focus === 'function') {
|
|
replyForm.focus()
|
|
}
|
|
}, 500)
|
|
},
|
|
|
|
setPostCardRef(el: any, postId: number) {
|
|
if (el) {
|
|
this.postCardRefs.set(postId, el)
|
|
} else {
|
|
this.postCardRefs.delete(postId)
|
|
}
|
|
},
|
|
|
|
async handleUpdate(data: { post: Post; content: string }): Promise<void> {
|
|
const postCard = this.postCardRefs.get(data.post.id)
|
|
|
|
try {
|
|
const response = await ocs.put<Post>(`/posts/${data.post.id}`, {
|
|
content: data.content,
|
|
})
|
|
|
|
if (response.data) {
|
|
// Update the post in the local posts array
|
|
const index = this.posts.findIndex((p) => p.id === data.post.id)
|
|
if (index !== -1) {
|
|
// Preserve reactions when updating
|
|
this.posts[index] = { ...response.data, reactions: this.posts[index]?.reactions || [] }
|
|
}
|
|
|
|
// Exit edit mode
|
|
if (postCard && typeof postCard.finishEdit === 'function') {
|
|
postCard.finishEdit()
|
|
}
|
|
|
|
showSuccess(t('forum', 'Post updated successfully'))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to update post', e)
|
|
showError(t('forum', 'Failed to update post'))
|
|
|
|
// Reset submitting state on error
|
|
if (postCard && typeof postCard.setEditSubmitting === 'function') {
|
|
postCard.setEditSubmitting(false)
|
|
}
|
|
}
|
|
},
|
|
|
|
async handleDelete(post: Post): Promise<void> {
|
|
try {
|
|
// If this is the first post, we're deleting the entire thread
|
|
const isFirstPost = this.posts.length > 0 && this.posts[0]?.id === post.id
|
|
|
|
if (isFirstPost) {
|
|
// Delete thread
|
|
if (!this.thread) {
|
|
return
|
|
}
|
|
|
|
const response = await ocs.delete<{ success: boolean; categorySlug: string }>(
|
|
`/threads/${this.thread.id}`,
|
|
)
|
|
|
|
if (response.data?.success && response.data.categorySlug) {
|
|
showSuccess(t('forum', 'Thread deleted successfully'))
|
|
// Navigate to the category
|
|
this.$router.push(`/c/${response.data.categorySlug}`)
|
|
}
|
|
} else {
|
|
// Delete post optimistically
|
|
await ocs.delete(`/posts/${post.id}`)
|
|
|
|
// Remove the post from the local array without refreshing
|
|
const index = this.posts.findIndex((p) => p.id === post.id)
|
|
if (index !== -1) {
|
|
this.posts.splice(index, 1)
|
|
}
|
|
|
|
showSuccess(t('forum', 'Post deleted successfully'))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to delete post', e)
|
|
showError(t('forum', 'Failed to delete post'))
|
|
}
|
|
},
|
|
|
|
replyToThread(): void {
|
|
const replyForm = this.$refs.replyForm as any
|
|
if (!replyForm) {
|
|
return
|
|
}
|
|
|
|
// Scroll to the reply form with smooth behavior
|
|
const element = replyForm.$el as HTMLElement
|
|
if (element) {
|
|
element.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'nearest',
|
|
inline: 'nearest',
|
|
})
|
|
}
|
|
|
|
// Wait for scroll animation to complete before focusing
|
|
setTimeout(() => {
|
|
if (replyForm && typeof replyForm.focus === 'function') {
|
|
replyForm.focus()
|
|
}
|
|
}, 500)
|
|
},
|
|
|
|
async handleSubmitReply(content: string): Promise<void> {
|
|
if (!this.thread) {
|
|
return
|
|
}
|
|
|
|
const replyForm = this.$refs.replyForm as any
|
|
|
|
try {
|
|
const response = await ocs.post<Post>('/posts', {
|
|
threadId: this.thread.id,
|
|
content,
|
|
})
|
|
|
|
// Append the new post to the existing posts array
|
|
if (response.data) {
|
|
// Add empty reactions array to the new post
|
|
const newPost = { ...response.data, reactions: [] }
|
|
this.posts.push(newPost)
|
|
|
|
// Clear the form only on success
|
|
if (replyForm && typeof replyForm.clear === 'function') {
|
|
replyForm.clear()
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to submit reply', e)
|
|
// Reset submitting state on error
|
|
if (replyForm && typeof replyForm.setSubmitting === 'function') {
|
|
replyForm.setSubmitting(false)
|
|
}
|
|
// TODO: Show error notification
|
|
}
|
|
},
|
|
|
|
handleCancelReply(): void {
|
|
// Optional: Could implement special behavior on cancel
|
|
console.log('Reply cancelled')
|
|
},
|
|
|
|
async handleToggleLock(): Promise<void> {
|
|
if (!this.thread) return
|
|
|
|
const newLockState = !this.thread.isLocked
|
|
|
|
try {
|
|
const response = await ocs.put(`/threads/${this.thread.id}/lock`, { locked: newLockState })
|
|
if (response.data) {
|
|
// Update local thread state
|
|
this.thread.isLocked = newLockState
|
|
showSuccess(newLockState ? this.strings.threadLocked : this.strings.threadUnlocked)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to toggle thread lock', e)
|
|
showError(t('forum', 'Failed to update thread lock status'))
|
|
}
|
|
},
|
|
|
|
async handleTogglePin(): Promise<void> {
|
|
if (!this.thread) return
|
|
|
|
const newPinState = !this.thread.isPinned
|
|
|
|
try {
|
|
const response = await ocs.put(`/threads/${this.thread.id}/pin`, { pinned: newPinState })
|
|
if (response.data) {
|
|
// Update local thread state
|
|
this.thread.isPinned = newPinState
|
|
showSuccess(newPinState ? this.strings.threadPinned : this.strings.threadUnpinned)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to toggle thread pin', e)
|
|
showError(t('forum', 'Failed to update thread pin status'))
|
|
}
|
|
},
|
|
|
|
async handleToggleSubscription(newValue: boolean): Promise<void> {
|
|
if (!this.thread) return
|
|
|
|
try {
|
|
if (newValue) {
|
|
// Subscribe to thread
|
|
await ocs.post(`/threads/${this.thread.id}/subscribe`)
|
|
this.thread.isSubscribed = true
|
|
showSuccess(this.strings.threadSubscribed)
|
|
} else {
|
|
// Unsubscribe from thread
|
|
await ocs.delete(`/threads/${this.thread.id}/subscribe`)
|
|
this.thread.isSubscribed = false
|
|
showSuccess(this.strings.threadUnsubscribed)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to toggle thread subscription', e)
|
|
showError(t('forum', 'Failed to update subscription'))
|
|
// Revert the state on error
|
|
this.thread.isSubscribed = !newValue
|
|
}
|
|
},
|
|
|
|
scrollToPostFromHash(): void {
|
|
// Check if there's a hash in the URL like #post-123
|
|
const hash = window.location.hash || this.$route.hash
|
|
|
|
if (hash && hash.startsWith('#post-')) {
|
|
const postId = parseInt(hash.replace('#post-', ''))
|
|
|
|
if (!isNaN(postId)) {
|
|
// Try immediately first
|
|
this.scrollToPost(postId)
|
|
|
|
// If that didn't work (refs not ready), try again after a short delay
|
|
setTimeout(() => {
|
|
this.scrollToPost(postId)
|
|
}, 100)
|
|
|
|
// Final attempt after a longer delay
|
|
setTimeout(() => {
|
|
this.scrollToPost(postId)
|
|
}, 500)
|
|
}
|
|
}
|
|
},
|
|
|
|
scrollToPost(postId: number): void {
|
|
// Get the PostCard component reference
|
|
const postCardRef = this.postCardRefs.get(postId)
|
|
|
|
if (postCardRef && postCardRef.$el) {
|
|
const element = postCardRef.$el as HTMLElement
|
|
const offset = 80 // Offset for toolbar and some breathing room
|
|
|
|
// Use requestAnimationFrame to ensure scroll happens after any router scroll operations
|
|
requestAnimationFrame(() => {
|
|
// Find the scrolling container - Nextcloud uses #app-content or #forum-main
|
|
const scrollContainer =
|
|
document.querySelector('#app-content') ||
|
|
document.querySelector('#forum-main') ||
|
|
document.documentElement
|
|
|
|
const elementTop = element.getBoundingClientRect().top
|
|
const scrollTop = scrollContainer.scrollTop || 0
|
|
const containerTop = scrollContainer.getBoundingClientRect().top
|
|
const targetPosition = elementTop - containerTop + scrollTop - offset
|
|
|
|
scrollContainer.scrollTo({
|
|
top: targetPosition,
|
|
behavior: 'smooth',
|
|
})
|
|
|
|
// Add highlight effect
|
|
element.classList.add('highlight-post')
|
|
setTimeout(() => {
|
|
element.classList.remove('highlight-post')
|
|
}, 2000)
|
|
})
|
|
}
|
|
},
|
|
|
|
goBack(): void {
|
|
// Always navigate to the category, not browser history
|
|
if (this.thread?.categorySlug) {
|
|
this.$router.push(`/c/${this.thread.categorySlug}`)
|
|
} else {
|
|
// Fallback to home if no category info
|
|
this.$router.push('/')
|
|
}
|
|
},
|
|
},
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.thread-view {
|
|
margin-bottom: 3rem;
|
|
|
|
.icon-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.muted {
|
|
color: var(--color-text-maxcontrast);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.mt-8 {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.mt-12 {
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.mt-16 {
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.ml-8 {
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.center {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
|
|
.thread-header {
|
|
padding: 20px;
|
|
background: var(--color-background-hover);
|
|
border-radius: 8px;
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
|
|
.thread-title-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.thread-title {
|
|
margin: 0;
|
|
font-size: 1.75rem;
|
|
font-weight: 600;
|
|
color: var(--color-main-text);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.badge {
|
|
font-size: 1.2rem;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
&.badge-pinned {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
&.badge-locked {
|
|
opacity: 0.8;
|
|
}
|
|
}
|
|
|
|
.thread-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 0.9rem;
|
|
color: var(--color-text-maxcontrast);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.meta-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.meta-label {
|
|
font-style: italic;
|
|
}
|
|
|
|
.meta-value {
|
|
font-weight: 500;
|
|
color: var(--color-text-lighter);
|
|
|
|
&.deleted-user {
|
|
font-style: italic;
|
|
opacity: 0.7;
|
|
}
|
|
}
|
|
|
|
.meta-divider {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.stat-icon {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.stat-value {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.posts-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.pagination-info {
|
|
text-align: center;
|
|
padding: 12px;
|
|
}
|
|
}
|
|
|
|
// Highlight animation for scrolled-to posts
|
|
:deep(.highlight-post) {
|
|
animation: highlightFade 2s ease-in-out;
|
|
}
|
|
|
|
@keyframes highlightFade {
|
|
0% {
|
|
background-color: var(--color-primary-element-light);
|
|
box-shadow: 0 0 0 4px var(--color-primary-element-light);
|
|
}
|
|
|
|
100% {
|
|
background-color: transparent;
|
|
box-shadow: none;
|
|
}
|
|
}
|
|
</style>
|