feat: current user endpoint, routing fixes

This commit is contained in:
2025-11-07 19:12:31 +02:00
parent d3c0590dca
commit 30edbc8330
13 changed files with 480 additions and 64 deletions

View File

@@ -100,7 +100,7 @@ class ForumUserController extends OCSController {
* 200: Current user's forum profile returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/users/me')]
#[ApiRoute(verb: 'GET', url: '/api/current-user')]
public function me(): DataResponse {
try {
$user = $this->userSession->getUser();

View File

@@ -8,8 +8,11 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\Db\BBCodeMapper;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Service\BBCodeService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -26,6 +29,9 @@ class PostController extends OCSController {
string $appName,
IRequest $request,
private PostMapper $postMapper,
private ThreadMapper $threadMapper,
private CategoryMapper $categoryMapper,
private ForumUserMapper $forumUserMapper,
private BBCodeService $bbCodeService,
private BBCodeMapper $bbCodeMapper,
private IUserSession $userSession,
@@ -107,20 +113,33 @@ class PostController extends OCSController {
*
* @param int $threadId Thread ID
* @param string $content Post content
* @param string $slug Post slug
* @param string|null $slug Post slug (auto-generated if not provided)
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
*
* 201: Post created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/posts')]
public function create(int $threadId, string $content, string $slug): DataResponse {
public function create(int $threadId, string $content, ?string $slug = null): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
// Ensure forum user exists - do not auto-create
try {
$forumUser = $this->forumUserMapper->findByUserId($user->getUID());
} catch (DoesNotExistException $e) {
// User must be registered in the forum before posting
return new DataResponse(['error' => 'User not registered in forum'], Http::STATUS_FORBIDDEN);
}
// Auto-generate slug if not provided
if ($slug === null || $slug === '') {
$slug = 'post-' . uniqid();
}
$post = new \OCA\Forum\Db\Post();
$post->setThreadId($threadId);
$post->setAuthorId($user->getUID());
@@ -132,6 +151,39 @@ class PostController extends OCSController {
/** @var \OCA\Forum\Db\Post */
$createdPost = $this->postMapper->insert($post);
// Update the thread's post count and timestamps
try {
$thread = $this->threadMapper->find($threadId);
$thread->setPostCount($thread->getPostCount() + 1);
$thread->setLastPostId($createdPost->getId());
$thread->setUpdatedAt(time());
$this->threadMapper->update($thread);
} catch (\Exception $e) {
$this->logger->warning('Failed to update thread post count: ' . $e->getMessage());
// Don't fail the request if thread update fails
}
// Update the forum user's post count
try {
$forumUser->setPostCount($forumUser->getPostCount() + 1);
$forumUser->setUpdatedAt(time());
$this->forumUserMapper->update($forumUser);
} catch (\Exception $e) {
$this->logger->warning('Failed to update forum user post count: ' . $e->getMessage());
// Don't fail the request if user update fails
}
// Update the category's post count
try {
$category = $this->categoryMapper->find($thread->getCategoryId());
$category->setPostCount($category->getPostCount() + 1);
$this->categoryMapper->update($category);
} catch (\Exception $e) {
$this->logger->warning('Failed to update category post count: ' . $e->getMessage());
// Don't fail the request if category update fails
}
return new DataResponse(Post::enrichPostContent($createdPost), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating post: ' . $e->getMessage());

View File

@@ -42,7 +42,7 @@ class ThreadController extends OCSController {
public function index(): DataResponse {
try {
$threads = $this->threadMapper->findAll();
return new DataResponse(array_map(fn ($t) => Thread::enrichThreadAuthor($t), $threads));
return new DataResponse(array_map(fn ($t) => Thread::enrichThread($t), $threads));
} catch (\Exception $e) {
$this->logger->error('Error fetching threads: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -64,7 +64,7 @@ class ThreadController extends OCSController {
public function byCategory(int $categoryId, int $limit = 50, int $offset = 0): DataResponse {
try {
$threads = $this->threadMapper->findByCategoryId($categoryId, $limit, $offset);
return new DataResponse(array_map(fn ($t) => Thread::enrichThreadAuthor($t), $threads));
return new DataResponse(array_map(fn ($t) => Thread::enrichThread($t), $threads));
} catch (\Exception $e) {
$this->logger->error('Error fetching threads by category: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -90,7 +90,7 @@ class ThreadController extends OCSController {
/** @var \OCA\Forum\Db\Thread */
$thread = $this->threadMapper->update($thread);
return new DataResponse(Thread::enrichThreadAuthor($thread));
return new DataResponse(Thread::enrichThread($thread));
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
@@ -118,7 +118,7 @@ class ThreadController extends OCSController {
/** @var \OCA\Forum\Db\Thread */
$thread = $this->threadMapper->update($thread);
return new DataResponse(Thread::enrichThreadAuthor($thread));
return new DataResponse(Thread::enrichThread($thread));
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {

View File

@@ -87,7 +87,7 @@ class Thread extends Entity implements JsonSerializable {
];
}
public static function enrichThreadAuthor(mixed $thread): array {
public static function enrichThread(mixed $thread): array {
if (!is_array($thread)) {
$thread = $thread->jsonSerialize();
}
@@ -104,6 +104,18 @@ class Thread extends Entity implements JsonSerializable {
$thread['authorIsDeleted'] = false;
}
// Add category information (slug and name) for navigation
try {
$categoryMapper = \OC::$server->get(\OCA\Forum\Db\CategoryMapper::class);
$category = $categoryMapper->find($thread['categoryId']);
$thread['categorySlug'] = $category->getSlug();
$thread['categoryName'] = $category->getName();
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
// Category doesn't exist
$thread['categorySlug'] = null;
$thread['categoryName'] = null;
}
return $thread;
}
}

View File

@@ -627,7 +627,8 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
['tag' => 'u', 'replacement' => '<u>{content}</u>', 'description' => 'Underlined text', 'parse_inner' => true],
['tag' => 'url', 'replacement' => '<a href="{href}" target="_blank" rel="noopener noreferrer">{content}</a>', 'description' => 'URL link', 'parse_inner' => true],
['tag' => 'img', 'replacement' => '<img src="{url}" alt="Image" class="forum-image" />', 'description' => 'Image', 'parse_inner' => true],
['tag' => 'code', 'replacement' => '<code>{content}</code>', 'description' => 'Inline code', 'parse_inner' => false],
['tag' => 'code', 'replacement' => '<pre><code>{content}</code></pre>', 'description' => 'Code block', 'parse_inner' => false],
['tag' => 'icode', 'replacement' => '<code>{content}</code>', 'description' => 'Inline code', 'parse_inner' => false],
['tag' => 'quote', 'replacement' => '<blockquote>{content}</blockquote>', 'description' => 'Quote', 'parse_inner' => true],
];

View File

@@ -60,6 +60,10 @@ class BBCodeService {
$escapedContent = preg_replace_callback(
$pattern,
function ($matches) use ($replacement, $params, &$protectedContent, &$placeholderIndex) {
// // Convert newlines to <br /> in the content before replacing
// $contentIndex = count($matches) - 1;
// $matches[$contentIndex] = nl2br($matches[$contentIndex]);
// Replace this BBCode but don't allow nested parsing
$result = $this->replaceBBCode($matches, $replacement, $params);

View File

@@ -3,11 +3,8 @@
<!-- Left sidebar -->
<NcAppNavigation>
<template #search>
<NcAppNavigationSearch
v-model="searchValue"
:label="strings.searchLabel"
:placeholder="strings.searchPlaceholder"
/>
<NcAppNavigationSearch v-model="searchValue" :label="strings.searchLabel"
:placeholder="strings.searchPlaceholder" />
</template>
<template #list>
@@ -17,28 +14,30 @@
</template>
<!-- Category headers as collapsible submenus -->
<NcAppNavigationItem
v-for="header in categoryHeaders"
:key="`header-${header.id}`"
:name="header.name"
:open="isHeaderOpen(header.id)"
@click.native.prevent="toggleHeader(header.id)"
>
<NcAppNavigationItem v-for="header in categoryHeaders" :key="`header-${header.id}`" :name="header.name"
@click="toggleHeader(header.id)">
<template #icon>
<FolderIcon :size="20" />
</template>
<template #actions>
<NcActionButton>
<template #icon>
<ChevronDownIcon v-if="isHeaderOpen(header.id)" :size="20" />
<ChevronRightIcon v-else :size="20" />
</template>
</NcActionButton>
</template>
<!-- Categories under each header -->
<NcAppNavigationItem
v-for="category in header.categories"
:key="`category-${category.id}`"
:name="category.name"
:to="{ path: `/c/${category.slug}` }"
>
<template #icon>
<ForumIcon :size="20" />
</template>
</NcAppNavigationItem>
<template v-if="isHeaderOpen(header.id)">
<NcAppNavigationItem v-for="category in header.categories" :key="`category-${category.id}`"
:name="category.name" :to="{ path: `/c/${category.slug}` }">
<template #icon>
<ForumIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigationItem>
</NcAppNavigationItem>
</template>
@@ -76,11 +75,14 @@ import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import HomeIcon from '@icons/Home.vue'
import ForumIcon from '@icons/Forum.vue'
import FolderIcon from '@icons/Folder.vue'
import PuzzleIcon from '@icons/Puzzle.vue'
import InfoIcon from '@icons/Information.vue'
import ChevronDownIcon from '@icons/ChevronDown.vue'
import ChevronRightIcon from '@icons/ChevronRight.vue'
import { useCategories } from '@/composables/useCategories'
export default defineComponent({
@@ -92,11 +94,14 @@ export default defineComponent({
NcAppNavigationItem,
NcAppNavigationSearch,
NcLoadingIcon,
NcActionButton,
HomeIcon,
ForumIcon,
FolderIcon,
PuzzleIcon,
InfoIcon,
ChevronDownIcon,
ChevronRightIcon,
},
setup() {
const { categoryHeaders, fetchCategories } = useCategories()
@@ -191,8 +196,7 @@ export default defineComponent({
}
#forum-content {
flex-basis: 100%;
flex: 1;
min-height: 100%;
display: flex;
flex-direction: column;
max-width: calc(100% - 128px);
@@ -215,8 +219,9 @@ export default defineComponent({
#forum-router {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: 3rem; // Add extra bottom padding
min-height: 0;
}
.router-loading {

View File

@@ -120,11 +120,6 @@ export default defineComponent({
padding: 16px;
background: var(--color-main-background);
transition: box-shadow 0.2s ease;
cursor: pointer;
* {
cursor: inherit;
}
&.first-post {
background: var(--color-background-hover);
@@ -209,6 +204,38 @@ export default defineComponent({
:deep(br) {
line-height: 1.6;
}
// Code blocks ([code])
:deep(pre) {
background: var(--color-background-dark);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 16px;
margin: 12px 0;
overflow-x: auto;
code {
background: none;
padding: 0;
border: none;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
line-height: 1.5;
color: var(--color-main-text);
white-space: pre;
display: block;
}
}
// Inline code ([icode])
:deep(code) {
background: var(--color-background-dark);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9rem;
color: var(--color-main-text);
}
}
.icon {

View File

@@ -0,0 +1,202 @@
<template>
<div class="post-reply-form">
<div class="reply-header">
<div class="user-info">
<NcAvatar v-if="userId" :user="userId" :size="40" />
<NcAvatar v-else :display-name="displayName" :size="40" />
<span class="user-name">{{ displayName }}</span>
</div>
</div>
<div class="reply-body">
<textarea
v-model="content"
class="reply-textarea"
:placeholder="strings.placeholder"
rows="4"
:disabled="submitting"
@keydown.ctrl.enter="submitReply"
@keydown.meta.enter="submitReply"
></textarea>
<div class="reply-footer">
<div class="reply-footer-left">
<span class="hint">{{ strings.hint }}</span>
</div>
<div class="reply-footer-right">
<NcButton @click="cancel" :disabled="submitting || !hasContent">
{{ strings.cancel }}
</NcButton>
<NcButton @click="submitReply" :disabled="!canSubmit || submitting">
<template v-if="submitting">
<NcLoadingIcon :size="20" />
</template>
{{ strings.submit }}
</NcButton>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
export default defineComponent({
name: 'PostReplyForm',
components: {
NcAvatar,
NcButton,
NcLoadingIcon,
},
emits: ['submit', 'cancel'],
setup() {
const { userId, displayName, fetchCurrentUser } = useCurrentUser()
// Fetch current user on mount
fetchCurrentUser()
return {
userId,
displayName,
}
},
data() {
return {
content: '',
submitting: false,
strings: {
placeholder: t('forum', 'Write your reply...'),
hint: t('forum', 'Ctrl+Enter to submit'),
cancel: t('forum', 'Cancel'),
submit: t('forum', 'Post Reply'),
confirmCancel: t('forum', 'Are you sure you want to discard your reply?'),
},
}
},
computed: {
canSubmit(): boolean {
return this.content.trim().length > 0
},
hasContent(): boolean {
return this.content.trim().length > 0
},
},
methods: {
async submitReply(): Promise<void> {
if (!this.canSubmit || this.submitting) {
return
}
this.submitting = true
this.$emit('submit', this.content.trim())
},
clear(): void {
this.content = ''
this.submitting = false
},
setSubmitting(value: boolean): void {
this.submitting = value
},
cancel(): void {
// Only confirm if there's content to discard
if (this.hasContent) {
// eslint-disable-next-line no-alert
if (!confirm(this.strings.confirmCancel)) {
return
}
}
this.content = ''
this.$emit('cancel')
},
},
})
</script>
<style scoped lang="scss">
.post-reply-form {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
background: var(--color-main-background);
margin-top: 24px;
}
.reply-header {
margin-bottom: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
}
.reply-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.reply-textarea {
width: 100%;
min-height: 100px;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-main-background);
color: var(--color-main-text);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.5;
resize: vertical;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: var(--color-primary-element);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.reply-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.reply-footer-left {
flex: 1;
}
.reply-footer-right {
display: flex;
gap: 8px;
}
.hint {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,71 @@
import { ref, computed, type Ref } from 'vue'
import { ocs } from '@/axios'
import type { ForumUser } from '@/types'
import { getCurrentUser } from '@nextcloud/auth'
const currentUser = ref<ForumUser | null>(null)
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
const loaded = ref<boolean>(false)
export function useCurrentUser() {
const fetchCurrentUser = async (force = false): Promise<ForumUser | null> => {
// Don't refetch if already loaded unless forced
if (loaded.value && !force) {
return currentUser.value
}
try {
loading.value = true
error.value = null
const response = await ocs.get<ForumUser>('/current-user')
currentUser.value = response.data
loaded.value = true
return currentUser.value
} catch (e: any) {
// If 404, user hasn't been created yet - this is OK, we'll use Nextcloud user info
if (e?.response?.status === 404) {
console.debug('Forum user not found, will be created on first post')
currentUser.value = null
loaded.value = true
return null
}
console.error('Failed to fetch current user', e)
error.value = (e as Error).message || 'Failed to load user information'
return null
} finally {
loading.value = false
}
}
const refresh = async (): Promise<ForumUser | null> => {
return fetchCurrentUser(true)
}
const clear = (): void => {
currentUser.value = null
loaded.value = false
error.value = null
}
// Get the Nextcloud user info (for display name, avatar, etc.)
const nextcloudUser = computed(() => getCurrentUser())
// Computed properties for easy access
const userId = computed<string | null>(() => nextcloudUser.value?.uid || null)
const displayName = computed<string>(() => nextcloudUser.value?.displayName || nextcloudUser.value?.uid || 'Guest')
return {
currentUser: currentUser as Ref<ForumUser | null>,
loading: loading as Ref<boolean>,
error: error as Ref<string | null>,
loaded: loaded as Ref<boolean>,
userId,
displayName,
nextcloudUser,
fetchCurrentUser,
refresh,
clear,
}
}

View File

@@ -41,6 +41,8 @@ export interface Thread {
// Enriched fields (added by Thread::enrichThreadAuthor)
authorDisplayName?: string
authorIsDeleted?: boolean
categorySlug?: string | null
categoryName?: string | null
}
export interface Post {

View File

@@ -192,8 +192,9 @@ export default defineComponent({
// Example: this.$router.push({ name: 'new-thread', params: { categoryId: this.category.id } })
},
goBack() {
this.$router.back()
goBack(): void {
// Always navigate to home, not browser history
this.$router.push('/')
},
},
})

View File

@@ -21,12 +21,7 @@
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<NcEmptyContent v-else-if="error" :title="strings.errorTitle" :description="error" class="mt-16">
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
@@ -69,15 +64,8 @@
<!-- 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"
:post="post"
:is-first-post="index === 0"
@reply="handleReply"
@edit="handleEdit"
@delete="handleDelete"
/>
<PostCard v-for="(post, index) in posts" :key="post.id" :post="post" :is-first-post="index === 0"
@reply="handleReply" @edit="handleEdit" @delete="handleDelete" />
</div>
<!-- Pagination info -->
@@ -87,16 +75,20 @@
</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"
>
<NcEmptyContent v-else-if="!loading && !error && thread && posts.length === 0" :title="strings.emptyPostsTitle"
:description="strings.emptyPostsDesc" class="mt-16">
<template #action>
<NcButton @click="replyToThread">{{ strings.reply }}</NcButton>
</template>
</NcEmptyContent>
<!-- Reply form -->
<PostReplyForm
v-if="!loading && !error && thread && !thread.isLocked"
ref="replyForm"
@submit="handleSubmitReply"
@cancel="handleCancelReply"
/>
</div>
</template>
@@ -107,6 +99,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import PostCard from '@/components/PostCard.vue'
import PostReplyForm from '@/components/PostReplyForm.vue'
import PinIcon from '@icons/Pin.vue'
import LockIcon from '@icons/Lock.vue'
import EyeIcon from '@icons/Eye.vue'
@@ -122,6 +115,7 @@ export default defineComponent({
NcLoadingIcon,
NcDateTime,
PostCard,
PostReplyForm,
PinIcon,
LockIcon,
EyeIcon,
@@ -264,11 +258,54 @@ export default defineComponent({
replyToThread(): void {
console.log('Reply to thread:', this.thread?.id)
// TODO: Implement reply to thread functionality
// Could open a reply form at the bottom or navigate to a reply page
// Could scroll to the reply form at the bottom
},
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) {
this.posts.push(response.data)
// 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')
},
goBack(): void {
this.$router.back()
// 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('/')
}
},
},
})
@@ -276,6 +313,8 @@ export default defineComponent({
<style scoped lang="scss">
.thread-view {
margin-bottom: 3rem;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;