From 30edbc8330fab18e484e704a12ad8fbfe9a18544 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Fri, 7 Nov 2025 19:12:31 +0200 Subject: [PATCH] feat: current user endpoint, routing fixes --- lib/Controller/ForumUserController.php | 2 +- lib/Controller/PostController.php | 56 ++++- lib/Controller/ThreadController.php | 8 +- lib/Db/Thread.php | 14 +- lib/Migration/Version1Date20251106004226.php | 3 +- lib/Service/BBCodeService.php | 4 + src/App.vue | 55 ++--- src/components/PostCard.vue | 37 +++- src/components/PostReplyForm.vue | 202 +++++++++++++++++++ src/composables/useCurrentUser.ts | 71 +++++++ src/types/models.ts | 2 + src/views/CategoryView.vue | 5 +- src/views/ThreadView.vue | 85 +++++--- 13 files changed, 480 insertions(+), 64 deletions(-) create mode 100644 src/components/PostReplyForm.vue create mode 100644 src/composables/useCurrentUser.ts diff --git a/lib/Controller/ForumUserController.php b/lib/Controller/ForumUserController.php index 643dbc9..b3be140 100644 --- a/lib/Controller/ForumUserController.php +++ b/lib/Controller/ForumUserController.php @@ -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(); diff --git a/lib/Controller/PostController.php b/lib/Controller/PostController.php index 8f1e27a..22b3bd2 100644 --- a/lib/Controller/PostController.php +++ b/lib/Controller/PostController.php @@ -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, 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()); diff --git a/lib/Controller/ThreadController.php b/lib/Controller/ThreadController.php index fe0aaa9..47e8011 100644 --- a/lib/Controller/ThreadController.php +++ b/lib/Controller/ThreadController.php @@ -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) { diff --git a/lib/Db/Thread.php b/lib/Db/Thread.php index 95ddd0a..861315e 100644 --- a/lib/Db/Thread.php +++ b/lib/Db/Thread.php @@ -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; } } diff --git a/lib/Migration/Version1Date20251106004226.php b/lib/Migration/Version1Date20251106004226.php index 5104f21..32b6678 100644 --- a/lib/Migration/Version1Date20251106004226.php +++ b/lib/Migration/Version1Date20251106004226.php @@ -627,7 +627,8 @@ class Version1Date20251106004226 extends SimpleMigrationStep { ['tag' => 'u', 'replacement' => '{content}', 'description' => 'Underlined text', 'parse_inner' => true], ['tag' => 'url', 'replacement' => '{content}', 'description' => 'URL link', 'parse_inner' => true], ['tag' => 'img', 'replacement' => 'Image', 'description' => 'Image', 'parse_inner' => true], - ['tag' => 'code', 'replacement' => '{content}', 'description' => 'Inline code', 'parse_inner' => false], + ['tag' => 'code', 'replacement' => '
{content}
', 'description' => 'Code block', 'parse_inner' => false], + ['tag' => 'icode', 'replacement' => '{content}', 'description' => 'Inline code', 'parse_inner' => false], ['tag' => 'quote', 'replacement' => '
{content}
', 'description' => 'Quote', 'parse_inner' => true], ]; diff --git a/lib/Service/BBCodeService.php b/lib/Service/BBCodeService.php index badc79a..e8962f9 100644 --- a/lib/Service/BBCodeService.php +++ b/lib/Service/BBCodeService.php @@ -60,6 +60,10 @@ class BBCodeService { $escapedContent = preg_replace_callback( $pattern, function ($matches) use ($replacement, $params, &$protectedContent, &$placeholderIndex) { + // // Convert newlines to
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); diff --git a/src/App.vue b/src/App.vue index 072517c..f4caf6b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,11 +3,8 @@ - + + + - - - + @@ -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 { diff --git a/src/components/PostCard.vue b/src/components/PostCard.vue index dbed8b9..cd669d9 100644 --- a/src/components/PostCard.vue +++ b/src/components/PostCard.vue @@ -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 { diff --git a/src/components/PostReplyForm.vue b/src/components/PostReplyForm.vue new file mode 100644 index 0000000..c9d2b9a --- /dev/null +++ b/src/components/PostReplyForm.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/composables/useCurrentUser.ts b/src/composables/useCurrentUser.ts new file mode 100644 index 0000000..c26b330 --- /dev/null +++ b/src/composables/useCurrentUser.ts @@ -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(null) +const loading = ref(false) +const error = ref(null) +const loaded = ref(false) + +export function useCurrentUser() { + const fetchCurrentUser = async (force = false): Promise => { + // 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('/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 => { + 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(() => nextcloudUser.value?.uid || null) + const displayName = computed(() => nextcloudUser.value?.displayName || nextcloudUser.value?.uid || 'Guest') + + return { + currentUser: currentUser as Ref, + loading: loading as Ref, + error: error as Ref, + loaded: loaded as Ref, + userId, + displayName, + nextcloudUser, + fetchCurrentUser, + refresh, + clear, + } +} diff --git a/src/types/models.ts b/src/types/models.ts index 47254e5..1f93580 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -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 { diff --git a/src/views/CategoryView.vue b/src/views/CategoryView.vue index fa7be77..db4afa2 100644 --- a/src/views/CategoryView.vue +++ b/src/views/CategoryView.vue @@ -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('/') }, }, }) diff --git a/src/views/ThreadView.vue b/src/views/ThreadView.vue index 0cf9340..5e0568a 100644 --- a/src/views/ThreadView.vue +++ b/src/views/ThreadView.vue @@ -21,12 +21,7 @@ - + @@ -69,15 +64,8 @@
- +
@@ -87,16 +75,20 @@
- + + + + @@ -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 { + if (!this.thread) { + return + } + + const replyForm = this.$refs.replyForm as any + + try { + const response = await ocs.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({