mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: current user endpoint, routing fixes
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
55
src/App.vue
55
src/App.vue
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
202
src/components/PostReplyForm.vue
Normal file
202
src/components/PostReplyForm.vue
Normal 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>
|
||||
71
src/composables/useCurrentUser.ts
Normal file
71
src/composables/useCurrentUser.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('/')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user