feat: bbcode parsing

This commit is contained in:
2025-11-07 02:24:39 +02:00
parent b9ae4ce1e3
commit 14204522b6
37 changed files with 1856 additions and 1830 deletions

16
appinfo/routes.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
return [
'routes' => [
// SPA catch-all routes - serve the main template for all sub-paths
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
['name' => 'page#catchAll', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.*']],
],
];

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\AttachmentMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -34,6 +35,7 @@ class AttachmentController extends OCSController {
*
* 200: Attachments returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/posts/{postId}/attachments')]
public function byPost(int $postId): DataResponse {
try {
@@ -53,6 +55,7 @@ class AttachmentController extends OCSController {
*
* 200: Attachment returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/attachments/{id}')]
public function show(int $id): DataResponse {
try {
@@ -76,6 +79,7 @@ class AttachmentController extends OCSController {
*
* 201: Attachment created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/attachments')]
public function create(int $postId, int $fileid, string $filename): DataResponse {
try {
@@ -85,6 +89,7 @@ class AttachmentController extends OCSController {
$attachment->setFilename($filename);
$attachment->setCreatedAt(time());
/** @var \OCA\Forum\Db\Attachment */
$createdAttachment = $this->attachmentMapper->insert($attachment);
return new DataResponse($createdAttachment->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -101,6 +106,7 @@ class AttachmentController extends OCSController {
*
* 200: Attachment deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/attachments/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\BBCodeMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -33,6 +34,7 @@ class BBCodeController extends OCSController {
*
* 200: BBCodes returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/bbcodes')]
public function index(): DataResponse {
try {
@@ -51,6 +53,7 @@ class BBCodeController extends OCSController {
*
* 200: Enabled BBCodes returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/bbcodes/enabled')]
public function enabled(): DataResponse {
try {
@@ -70,6 +73,7 @@ class BBCodeController extends OCSController {
*
* 200: BBCode returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/bbcodes/{id}')]
public function show(int $id): DataResponse {
try {
@@ -94,6 +98,7 @@ class BBCodeController extends OCSController {
*
* 201: BBCode created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/bbcodes')]
public function create(string $tag, string $replacement, ?string $description = null, bool $enabled = true): DataResponse {
try {
@@ -104,6 +109,7 @@ class BBCodeController extends OCSController {
$bbcode->setEnabled($enabled);
$bbcode->setCreatedAt(time());
/** @var \OCA\Forum\Db\BBCode */
$createdBBCode = $this->bbCodeMapper->insert($bbcode);
return new DataResponse($createdBBCode->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -124,6 +130,7 @@ class BBCodeController extends OCSController {
*
* 200: BBCode updated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/bbcodes/{id}')]
public function update(int $id, ?string $tag = null, ?string $replacement = null, ?string $description = null, ?bool $enabled = null): DataResponse {
try {
@@ -142,6 +149,7 @@ class BBCodeController extends OCSController {
$bbcode->setEnabled($enabled);
}
/** @var \OCA\Forum\Db\BBCode */
$updatedBBCode = $this->bbCodeMapper->update($bbcode);
return new DataResponse($updatedBBCode->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -160,6 +168,7 @@ class BBCodeController extends OCSController {
*
* 200: BBCode deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/bbcodes/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\CatHeaderMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -33,6 +34,7 @@ class CatHeaderController extends OCSController {
*
* 200: Category headers returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/headers')]
public function index(): DataResponse {
try {
@@ -52,6 +54,7 @@ class CatHeaderController extends OCSController {
*
* 200: Category header returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/headers/{id}')]
public function show(int $id): DataResponse {
try {
@@ -75,6 +78,7 @@ class CatHeaderController extends OCSController {
*
* 201: Category header created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/headers')]
public function create(string $name, ?string $description = null, int $sortOrder = 0): DataResponse {
try {
@@ -84,6 +88,7 @@ class CatHeaderController extends OCSController {
$header->setSortOrder($sortOrder);
$header->setCreatedAt(time());
/** @var \OCA\Forum\Db\CatHeader */
$createdHeader = $this->catHeaderMapper->insert($header);
return new DataResponse($createdHeader->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -103,6 +108,7 @@ class CatHeaderController extends OCSController {
*
* 200: Category header updated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/headers/{id}')]
public function update(int $id, ?string $name = null, ?string $description = null, ?int $sortOrder = null): DataResponse {
try {
@@ -118,6 +124,7 @@ class CatHeaderController extends OCSController {
$header->setSortOrder($sortOrder);
}
/** @var \OCA\Forum\Db\CatHeader */
$updatedHeader = $this->catHeaderMapper->update($header);
return new DataResponse($updatedHeader->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -136,6 +143,7 @@ class CatHeaderController extends OCSController {
*
* 200: Category header deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/headers/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -12,6 +12,7 @@ use OCA\Forum\Db\CatHeaderMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -35,6 +36,7 @@ class CategoryController extends OCSController {
*
* 200: Category headers with nested categories returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/categories')]
public function index(): DataResponse {
try {
@@ -75,6 +77,7 @@ class CategoryController extends OCSController {
*
* 200: Categories returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/headers/{headerId}/categories')]
public function byHeader(int $headerId): DataResponse {
try {
@@ -94,6 +97,7 @@ class CategoryController extends OCSController {
*
* 200: Category returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/categories/{id}')]
public function show(int $id): DataResponse {
try {
@@ -115,6 +119,7 @@ class CategoryController extends OCSController {
*
* 200: Category returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/categories/slug/{slug}')]
public function bySlug(string $slug): DataResponse {
try {
@@ -140,6 +145,7 @@ class CategoryController extends OCSController {
*
* 201: Category created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/categories')]
public function create(int $headerId, string $name, string $slug, ?string $description = null, int $sortOrder = 0): DataResponse {
try {
@@ -154,6 +160,7 @@ class CategoryController extends OCSController {
$category->setCreatedAt(time());
$category->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Category */
$createdCategory = $this->categoryMapper->insert($category);
return new DataResponse($createdCategory->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -174,6 +181,7 @@ class CategoryController extends OCSController {
*
* 200: Category updated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/categories/{id}')]
public function update(int $id, ?string $name = null, ?string $description = null, ?string $slug = null, ?int $sortOrder = null): DataResponse {
try {
@@ -193,6 +201,7 @@ class CategoryController extends OCSController {
}
$category->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Category */
$updatedCategory = $this->categoryMapper->update($category);
return new DataResponse($updatedCategory->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -211,6 +220,7 @@ class CategoryController extends OCSController {
*
* 200: Category deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/categories/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\ForumUserMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -35,6 +36,7 @@ class ForumUserController extends OCSController {
*
* 200: Forum users returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/users')]
public function index(): DataResponse {
try {
@@ -54,6 +56,7 @@ class ForumUserController extends OCSController {
*
* 200: Forum user returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/users/{id}')]
public function show(int $id): DataResponse {
try {
@@ -75,6 +78,7 @@ class ForumUserController extends OCSController {
*
* 200: Forum user returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/users/by-uid/{userId}')]
public function byUserId(string $userId): DataResponse {
try {
@@ -95,6 +99,7 @@ class ForumUserController extends OCSController {
*
* 200: Current user's forum profile returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/users/me')]
public function me(): DataResponse {
try {
@@ -121,6 +126,7 @@ class ForumUserController extends OCSController {
*
* 201: Forum user created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/users')]
public function create(string $userId): DataResponse {
try {
@@ -130,6 +136,7 @@ class ForumUserController extends OCSController {
$forumUser->setCreatedAt(time());
$forumUser->setUpdatedAt(time());
/** @var \OCA\Forum\Db\ForumUser */
$createdUser = $this->forumUserMapper->insert($forumUser);
return new DataResponse($createdUser->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -147,6 +154,7 @@ class ForumUserController extends OCSController {
*
* 200: Forum user updated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/users/{id}')]
public function update(int $id, ?int $postCount = null): DataResponse {
try {
@@ -157,6 +165,7 @@ class ForumUserController extends OCSController {
}
$user->setUpdatedAt(time());
/** @var \OCA\Forum\Db\ForumUser */
$updatedUser = $this->forumUserMapper->update($user);
return new DataResponse($updatedUser->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -175,6 +184,7 @@ class ForumUserController extends OCSController {
*
* 200: Forum user deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/users/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -6,7 +6,6 @@ namespace OCA\Forum\Controller;
use OCA\Forum\AppInfo\Application;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\TemplateResponse;
@@ -32,11 +31,23 @@ class PageController extends Controller {
*/
#[NoAdminRequired]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/')]
public function index(): TemplateResponse {
$this->logger->info('Forum main page loaded');
return new TemplateResponse(Application::APP_ID, 'app', [
'script' => 'app',
]);
}
/**
* Main app page - catch all route
*
* @return TemplateResponse<Http::STATUS_OK,array{}>
*
* 200: OK
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function catchAll(string $path = ''): TemplateResponse {
return $this->index();
}
}

View File

@@ -7,10 +7,14 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\Db\BBCodeMapper;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Service\BBCodeService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -22,6 +26,8 @@ class PostController extends OCSController {
string $appName,
IRequest $request,
private PostMapper $postMapper,
private BBCodeService $bbCodeService,
private BBCodeMapper $bbCodeMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -38,11 +44,14 @@ class PostController extends OCSController {
*
* 200: Posts returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/threads/{threadId}/posts')]
public function byThread(int $threadId, int $limit = 50, int $offset = 0): DataResponse {
try {
$posts = $this->postMapper->findByThreadId($threadId, $limit, $offset);
return new DataResponse(array_map(fn ($p) => $p->jsonSerialize(), $posts));
// Prefetch BBCodes once for all posts to avoid repeated queries
$bbcodes = $this->bbCodeMapper->findAllEnabled();
return new DataResponse(array_map(fn ($p) => Post::enrichPostContent($p, $bbcodes), $posts));
} catch (\Exception $e) {
$this->logger->error('Error fetching posts by thread: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch posts'], Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -57,11 +66,12 @@ class PostController extends OCSController {
*
* 200: Post returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/posts/{id}')]
public function show(int $id): DataResponse {
try {
$post = $this->postMapper->find($id);
return new DataResponse($post->jsonSerialize());
return new DataResponse(Post::enrichPostContent($post));
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
@@ -78,11 +88,12 @@ class PostController extends OCSController {
*
* 200: Post returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/posts/slug/{slug}')]
public function bySlug(string $slug): DataResponse {
try {
$post = $this->postMapper->findBySlug($slug);
return new DataResponse($post->jsonSerialize());
return new DataResponse(Post::enrichPostContent($post));
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
@@ -101,6 +112,7 @@ class PostController extends OCSController {
*
* 201: Post created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/posts')]
public function create(int $threadId, string $content, string $slug): DataResponse {
try {
@@ -118,8 +130,9 @@ class PostController extends OCSController {
$post->setCreatedAt(time());
$post->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Post */
$createdPost = $this->postMapper->insert($post);
return new DataResponse($createdPost->jsonSerialize(), Http::STATUS_CREATED);
return new DataResponse(Post::enrichPostContent($createdPost), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating post: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to create post'], Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -135,6 +148,7 @@ class PostController extends OCSController {
*
* 200: Post updated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/posts/{id}')]
public function update(int $id, ?string $content = null): DataResponse {
try {
@@ -147,8 +161,9 @@ class PostController extends OCSController {
}
$post->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Post */
$updatedPost = $this->postMapper->update($post);
return new DataResponse($updatedPost->jsonSerialize());
return new DataResponse(Post::enrichPostContent($updatedPost));
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
@@ -165,6 +180,7 @@ class PostController extends OCSController {
*
* 200: Post deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/posts/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\ReactionMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -36,6 +37,7 @@ class ReactionController extends OCSController {
*
* 200: Reactions returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/posts/{postId}/reactions')]
public function byPost(int $postId): DataResponse {
try {
@@ -55,6 +57,7 @@ class ReactionController extends OCSController {
*
* 200: Reaction returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/reactions/{id}')]
public function show(int $id): DataResponse {
try {
@@ -77,6 +80,7 @@ class ReactionController extends OCSController {
*
* 201: Reaction created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/reactions')]
public function create(int $postId, string $reactionType): DataResponse {
try {
@@ -91,6 +95,7 @@ class ReactionController extends OCSController {
$reaction->setReactionType($reactionType);
$reaction->setCreatedAt(time());
/** @var \OCA\Forum\Db\Reaction */
$createdReaction = $this->reactionMapper->insert($reaction);
return new DataResponse($createdReaction->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -107,6 +112,7 @@ class ReactionController extends OCSController {
*
* 200: Reaction deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/reactions/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\ReadMarkerMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -35,6 +36,7 @@ class ReadMarkerController extends OCSController {
*
* 200: Read markers returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/read-markers')]
public function index(): DataResponse {
try {
@@ -59,6 +61,7 @@ class ReadMarkerController extends OCSController {
*
* 200: Read marker returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/threads/{threadId}/read-marker')]
public function show(int $threadId): DataResponse {
try {
@@ -86,6 +89,7 @@ class ReadMarkerController extends OCSController {
*
* 200: Thread marked as read
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/read-markers')]
public function create(int $threadId, int $lastReadPostId): DataResponse {
try {
@@ -99,6 +103,7 @@ class ReadMarkerController extends OCSController {
$marker = $this->readMarkerMapper->findByUserAndThread($user->getUID(), $threadId);
$marker->setLastReadPostId($lastReadPostId);
$marker->setReadAt(time());
/** @var \OCA\Forum\Db\ReadMarker */
$updatedMarker = $this->readMarkerMapper->update($marker);
return new DataResponse($updatedMarker->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -109,6 +114,7 @@ class ReadMarkerController extends OCSController {
$marker->setLastReadPostId($lastReadPostId);
$marker->setReadAt(time());
/** @var \OCA\Forum\Db\ReadMarker */
$createdMarker = $this->readMarkerMapper->insert($marker);
return new DataResponse($createdMarker->jsonSerialize());
}
@@ -126,6 +132,7 @@ class ReadMarkerController extends OCSController {
*
* 200: Read marker deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/read-markers/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\RoleMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -33,6 +34,7 @@ class RoleController extends OCSController {
*
* 200: Roles returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/roles')]
public function index(): DataResponse {
try {
@@ -52,6 +54,7 @@ class RoleController extends OCSController {
*
* 200: Role returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/roles/{id}')]
public function show(int $id): DataResponse {
try {
@@ -74,6 +77,7 @@ class RoleController extends OCSController {
*
* 201: Role created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/roles')]
public function create(string $name, ?string $description = null): DataResponse {
try {
@@ -82,6 +86,7 @@ class RoleController extends OCSController {
$role->setDescription($description);
$role->setCreatedAt(time());
/** @var \OCA\Forum\Db\Role */
$createdRole = $this->roleMapper->insert($role);
return new DataResponse($createdRole->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -100,6 +105,7 @@ class RoleController extends OCSController {
*
* 200: Role updated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/roles/{id}')]
public function update(int $id, ?string $name = null, ?string $description = null): DataResponse {
try {
@@ -112,6 +118,7 @@ class RoleController extends OCSController {
$role->setDescription($description);
}
/** @var \OCA\Forum\Db\Role */
$updatedRole = $this->roleMapper->update($role);
return new DataResponse($updatedRole->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -130,6 +137,7 @@ class RoleController extends OCSController {
*
* 200: Role deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/roles/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\ThreadMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -35,6 +36,7 @@ class ThreadController extends OCSController {
*
* 200: Threads returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/threads')]
public function index(): DataResponse {
try {
@@ -56,6 +58,7 @@ class ThreadController extends OCSController {
*
* 200: Threads returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/categories/{categoryId}/threads')]
public function byCategory(int $categoryId, int $limit = 50, int $offset = 0): DataResponse {
try {
@@ -75,6 +78,7 @@ class ThreadController extends OCSController {
*
* 200: Thread returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/threads/{id}')]
public function show(int $id): DataResponse {
try {
@@ -82,7 +86,8 @@ class ThreadController extends OCSController {
// Increment view count
$thread->setViewCount($thread->getViewCount() + 1);
$this->threadMapper->update($thread);
/** @var \OCA\Forum\Db\Thread */
$thread = $this->threadMapper->update($thread);
return new DataResponse($thread->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -101,6 +106,7 @@ class ThreadController extends OCSController {
*
* 200: Thread returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/threads/slug/{slug}')]
public function bySlug(string $slug): DataResponse {
try {
@@ -108,7 +114,8 @@ class ThreadController extends OCSController {
// Increment view count
$thread->setViewCount($thread->getViewCount() + 1);
$this->threadMapper->update($thread);
/** @var \OCA\Forum\Db\Thread */
$thread = $this->threadMapper->update($thread);
return new DataResponse($thread->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -129,6 +136,7 @@ class ThreadController extends OCSController {
*
* 201: Thread created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/threads')]
public function create(int $categoryId, string $title, string $slug): DataResponse {
try {
@@ -150,6 +158,7 @@ class ThreadController extends OCSController {
$thread->setCreatedAt(time());
$thread->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Thread */
$createdThread = $this->threadMapper->insert($thread);
return new DataResponse($createdThread->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -170,6 +179,7 @@ class ThreadController extends OCSController {
*
* 200: Thread updated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/threads/{id}')]
public function update(int $id, ?string $title = null, ?bool $isLocked = null, ?bool $isPinned = null, ?bool $isHidden = null): DataResponse {
try {
@@ -189,6 +199,7 @@ class ThreadController extends OCSController {
}
$thread->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Thread */
$updatedThread = $this->threadMapper->update($thread);
return new DataResponse($updatedThread->jsonSerialize());
} catch (DoesNotExistException $e) {
@@ -207,6 +218,7 @@ class ThreadController extends OCSController {
*
* 200: Thread deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/threads/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\UserRoleMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
@@ -34,6 +35,7 @@ class UserRoleController extends OCSController {
*
* 200: User roles returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/users/{userId}/roles')]
public function byUser(string $userId): DataResponse {
try {
@@ -53,6 +55,7 @@ class UserRoleController extends OCSController {
*
* 200: User roles returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/roles/{roleId}/users')]
public function byRole(int $roleId): DataResponse {
try {
@@ -73,6 +76,7 @@ class UserRoleController extends OCSController {
*
* 201: Role assigned to user
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/user-roles')]
public function create(string $userId, int $roleId): DataResponse {
try {
@@ -81,6 +85,7 @@ class UserRoleController extends OCSController {
$userRole->setRoleId($roleId);
$userRole->setCreatedAt(time());
/** @var \OCA\Forum\Db\UserRole */
$createdUserRole = $this->userRoleMapper->insert($userRole);
return new DataResponse($createdUserRole->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
@@ -97,6 +102,7 @@ class UserRoleController extends OCSController {
*
* 200: Role removed from user
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/user-roles/{id}')]
public function destroy(int $id): DataResponse {
try {

View File

@@ -40,10 +40,10 @@ class Attachment extends Entity implements JsonSerializable {
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'post_id' => $this->getPostId(),
'postId' => $this->getPostId(),
'fileid' => $this->getFileid(),
'filename' => $this->getFilename(),
'created_at' => $this->getCreatedAt(),
'createdAt' => $this->getCreatedAt(),
];
}
}

View File

@@ -22,6 +22,8 @@ use OCP\AppFramework\Db\Entity;
* @method void setDescription(?string $value)
* @method bool getEnabled()
* @method void setEnabled(bool $value)
* @method bool getParseInner()
* @method void setParseInner(bool $value)
* @method int getCreatedAt()
* @method void setCreatedAt(int $value)
*/
@@ -30,6 +32,7 @@ class BBCode extends Entity implements JsonSerializable {
protected $replacement;
protected $description;
protected $enabled;
protected $parseInner;
protected $createdAt;
public function __construct() {
@@ -38,6 +41,7 @@ class BBCode extends Entity implements JsonSerializable {
$this->addType('replacement', 'string');
$this->addType('description', 'string');
$this->addType('enabled', 'boolean');
$this->addType('parseInner', 'boolean');
$this->addType('createdAt', 'integer');
}
@@ -48,7 +52,8 @@ class BBCode extends Entity implements JsonSerializable {
'replacement' => $this->getReplacement(),
'description' => $this->getDescription(),
'enabled' => $this->getEnabled(),
'created_at' => $this->getCreatedAt(),
'parseInner' => $this->getParseInner(),
'createdAt' => $this->getCreatedAt(),
];
}
}

View File

@@ -42,8 +42,8 @@ class CatHeader extends Entity implements JsonSerializable {
'id' => $this->getId(),
'name' => $this->getName(),
'description' => $this->getDescription(),
'sort_order' => $this->getSortOrder(),
'created_at' => $this->getCreatedAt(),
'sortOrder' => $this->getSortOrder(),
'createdAt' => $this->getCreatedAt(),
];
}
}

View File

@@ -60,15 +60,15 @@ class Category extends Entity implements JsonSerializable {
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'header_id' => $this->getHeaderId(),
'headerId' => $this->getHeaderId(),
'name' => $this->getName(),
'description' => $this->getDescription(),
'slug' => $this->getSlug(),
'sort_order' => $this->getSortOrder(),
'thread_count' => $this->getThreadCount(),
'post_count' => $this->getPostCount(),
'created_at' => $this->getCreatedAt(),
'updated_at' => $this->getUpdatedAt(),
'sortOrder' => $this->getSortOrder(),
'threadCount' => $this->getThreadCount(),
'postCount' => $this->getPostCount(),
'createdAt' => $this->getCreatedAt(),
'updatedAt' => $this->getUpdatedAt(),
];
}
}

View File

@@ -40,10 +40,10 @@ class ForumUser extends Entity implements JsonSerializable {
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'user_id' => $this->getUserId(),
'post_count' => $this->getPostCount(),
'created_at' => $this->getCreatedAt(),
'updated_at' => $this->getUpdatedAt(),
'userId' => $this->getUserId(),
'postCount' => $this->getPostCount(),
'createdAt' => $this->getCreatedAt(),
'updatedAt' => $this->getUpdatedAt(),
];
}
}

View File

@@ -56,14 +56,28 @@ class Post extends Entity implements JsonSerializable {
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'thread_id' => $this->getThreadId(),
'author_id' => $this->getAuthorId(),
'threadId' => $this->getThreadId(),
'authorId' => $this->getAuthorId(),
'content' => $this->getContent(),
'contentRaw' => $this->getContent(),
'slug' => $this->getSlug(),
'is_edited' => $this->getIsEdited(),
'edited_at' => $this->getEditedAt(),
'created_at' => $this->getCreatedAt(),
'updated_at' => $this->getUpdatedAt(),
'isEdited' => $this->getIsEdited(),
'editedAt' => $this->getEditedAt(),
'createdAt' => $this->getCreatedAt(),
'updatedAt' => $this->getUpdatedAt(),
];
}
public static function enrichPostContent(mixed $post, array $bbcodes = []): array {
if (!is_array($post)) {
$post = $post->jsonSerialize();
}
$service = \OC::$server->get(\OCA\Forum\Service\BBCodeService::class);
if (empty($bbcodes)) {
$mapper = \OC::$server->get(\OCA\Forum\Db\BBCodeMapper::class);
$bbcodes = $mapper->findAllEnabled();
}
$post['content'] = $service->parse($post['content'], $bbcodes);
return $post;
}
}

View File

@@ -40,10 +40,10 @@ class Reaction extends Entity implements JsonSerializable {
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'post_id' => $this->getPostId(),
'user_id' => $this->getUserId(),
'reaction_type' => $this->getReactionType(),
'created_at' => $this->getCreatedAt(),
'postId' => $this->getPostId(),
'userId' => $this->getUserId(),
'reactionType' => $this->getReactionType(),
'createdAt' => $this->getCreatedAt(),
];
}
}

View File

@@ -40,10 +40,10 @@ class ReadMarker extends Entity implements JsonSerializable {
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'user_id' => $this->getUserId(),
'thread_id' => $this->getThreadId(),
'last_read_post_id' => $this->getLastReadPostId(),
'read_at' => $this->getReadAt(),
'userId' => $this->getUserId(),
'threadId' => $this->getThreadId(),
'lastReadPostId' => $this->getLastReadPostId(),
'readAt' => $this->getReadAt(),
];
}
}

View File

@@ -38,7 +38,7 @@ class Role extends Entity implements JsonSerializable {
'id' => $this->getId(),
'name' => $this->getName(),
'description' => $this->getDescription(),
'created_at' => $this->getCreatedAt(),
'createdAt' => $this->getCreatedAt(),
];
}
}

View File

@@ -72,18 +72,18 @@ class Thread extends Entity implements JsonSerializable {
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'category_id' => $this->getCategoryId(),
'author_id' => $this->getAuthorId(),
'categoryId' => $this->getCategoryId(),
'authorId' => $this->getAuthorId(),
'title' => $this->getTitle(),
'slug' => $this->getSlug(),
'view_count' => $this->getViewCount(),
'post_count' => $this->getPostCount(),
'last_post_id' => $this->getLastPostId(),
'is_locked' => $this->getIsLocked(),
'is_pinned' => $this->getIsPinned(),
'is_hidden' => $this->getIsHidden(),
'created_at' => $this->getCreatedAt(),
'updated_at' => $this->getUpdatedAt(),
'viewCount' => $this->getViewCount(),
'postCount' => $this->getPostCount(),
'lastPostId' => $this->getLastPostId(),
'isLocked' => $this->getIsLocked(),
'isPinned' => $this->getIsPinned(),
'isHidden' => $this->getIsHidden(),
'createdAt' => $this->getCreatedAt(),
'updatedAt' => $this->getUpdatedAt(),
];
}
}

View File

@@ -36,9 +36,9 @@ class UserRole extends Entity implements JsonSerializable {
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'user_id' => $this->getUserId(),
'role_id' => $this->getRoleId(),
'created_at' => $this->getCreatedAt(),
'userId' => $this->getUserId(),
'roleId' => $this->getRoleId(),
'createdAt' => $this->getCreatedAt(),
];
}
}

View File

@@ -284,6 +284,10 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'notnull' => true,
'default' => true,
]);
$table->addColumn('parse_inner', 'boolean', [
'notnull' => true,
'default' => true,
]);
$table->addColumn('created_at', 'integer', [
'notnull' => true,
'unsigned' => true,
@@ -358,7 +362,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$table->addIndex(['category_id'], 'forum_threads_category_id_idx');
$table->addIndex(['author_id'], 'forum_threads_author_id_idx');
$table->addIndex(['last_post_id'], 'forum_threads_last_post_id_idx');
$table->addIndex(['is_pinned', 'updated_at'], 'forum_threads_pinned_updated_idx');
$table->addIndex(['is_pinned', 'updated_at'], 'forum_thread_pin_upd_idx');
}
private function createForumPostsTable(ISchemaWrapper $schema): void {
@@ -438,9 +442,9 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'unsigned' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['user_id'], 'forum_read_markers_user_id_idx');
$table->addIndex(['thread_id'], 'forum_read_markers_thread_id_idx');
$table->addUniqueIndex(['user_id', 'thread_id'], 'forum_read_markers_unique_idx');
$table->addIndex(['user_id'], 'forum_read_mark_uid_idx');
$table->addIndex(['thread_id'], 'forum_read_mark_tid_idx');
$table->addUniqueIndex(['user_id', 'thread_id'], 'forum_read_mark_uniq_idx');
}
private function createForumReactionsTable(ISchemaWrapper $schema): void {
@@ -515,6 +519,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$db = \OC::$server->get(\OCP\IDBConnection::class);
$userManager = \OC::$server->get(\OCP\IUserManager::class);
$timestamp = time();
// Create default roles
@@ -595,13 +600,13 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
// Create default BBCodes
$bbcodes = [
['tag' => 'b', 'replacement' => '<strong>{content}</strong>', 'description' => 'Bold text'],
['tag' => 'i', 'replacement' => '<em>{content}</em>', 'description' => 'Italic text'],
['tag' => 'u', 'replacement' => '<u>{content}</u>', 'description' => 'Underlined text'],
['tag' => 'url', 'replacement' => '<a href="{href}" target="_blank" rel="noopener noreferrer">{content}</a>', 'description' => 'URL link'],
['tag' => 'img', 'replacement' => '<img src="{url}" alt="Image" class="forum-image" />', 'description' => 'Image'],
['tag' => 'code', 'replacement' => '<code>{content}</code>', 'description' => 'Inline code'],
['tag' => 'quote', 'replacement' => '<blockquote>{content}</blockquote>', 'description' => 'Quote'],
['tag' => 'b', 'replacement' => '<strong>{content}</strong>', 'description' => 'Bold text', 'parse_inner' => true],
['tag' => 'i', 'replacement' => '<em>{content}</em>', 'description' => 'Italic text', 'parse_inner' => true],
['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' => 'quote', 'replacement' => '<blockquote>{content}</blockquote>', 'description' => 'Quote', 'parse_inner' => true],
];
foreach ($bbcodes as $bbcode) {
@@ -612,31 +617,53 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'replacement' => $qb->createNamedParameter($bbcode['replacement']),
'description' => $qb->createNamedParameter($bbcode['description']),
'enabled' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'parse_inner' => $qb->createNamedParameter($bbcode['parse_inner'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
}
// Create admin forum user
$qb = $db->getQueryBuilder();
$qb->insert('forum_users')
->values([
'user_id' => $qb->createNamedParameter('admin'),
'post_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
// Create forum users for all Nextcloud users
$groupManager = \OC::$server->get(\OCP\IGroupManager::class);
$adminGroup = $groupManager->get('admin');
// Assign admin role to admin user
$qb = $db->getQueryBuilder();
$qb->insert('forum_user_roles')
->values([
'user_id' => $qb->createNamedParameter('admin'),
'role_id' => $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $userRoleId, $adminRoleId, $adminGroup) {
$userId = $user->getUID();
$isAdmin = $adminGroup && $adminGroup->inGroup($user);
// Create forum user
$qb = $db->getQueryBuilder();
$qb->insert('forum_users')
->values([
'user_id' => $qb->createNamedParameter($userId),
'post_count' => $qb->createNamedParameter($userId === 'admin' ? 1 : 0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
// Assign User role to all users
$qb = $db->getQueryBuilder();
$qb->insert('forum_user_roles')
->values([
'user_id' => $qb->createNamedParameter($userId),
'role_id' => $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
// Assign Admin role to admin group members
if ($isAdmin) {
$qb = $db->getQueryBuilder();
$qb->insert('forum_user_roles')
->values([
'user_id' => $qb->createNamedParameter($userId),
'role_id' => $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
}
});
// Create welcome thread
$qb = $db->getQueryBuilder();

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
use OCA\Forum\Db\BBCode;
use OCA\Forum\Db\BBCodeMapper;
use Psr\Log\LoggerInterface;
class BBCodeService {
public function __construct(
private BBCodeMapper $bbCodeMapper,
private LoggerInterface $logger,
) {
}
/**
* Parse content with BBCode tags
*
* @param string $content The content to parse
* @param array<BBCode> $bbCodes Array of BBCode entities to use for parsing
* @return string The parsed content with BBCodes replaced by HTML
*/
public function parse(string $content, array $bbCodes): string {
// First, HTML escape the entire content to prevent XSS
$escapedContent = htmlspecialchars($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Separate BBCodes into those that parse inner content and those that don't
$noParseInner = [];
$parseInner = [];
foreach ($bbCodes as $bbCode) {
if (!$bbCode->getEnabled()) {
continue;
}
if ($bbCode->getParseInner()) {
$parseInner[] = $bbCode;
} else {
$noParseInner[] = $bbCode;
}
}
// Storage for protected content (BBCodes that don't parse inner)
$protectedContent = [];
$placeholderIndex = 0;
// First pass: Process BBCodes that don't parse inner content
// Replace them with placeholders to protect from further processing
foreach ($noParseInner as $bbCode) {
$tag = $bbCode->getTag();
$replacement = $bbCode->getReplacement();
$params = $this->extractParameters($replacement);
$pattern = $this->buildPattern($tag, $params);
$escapedContent = preg_replace_callback(
$pattern,
function ($matches) use ($replacement, $params, &$protectedContent, &$placeholderIndex) {
// Replace this BBCode but don't allow nested parsing
$result = $this->replaceBBCode($matches, $replacement, $params);
// Store the result and use a placeholder
$placeholder = "___BBCODE_PROTECTED_{$placeholderIndex}___";
$protectedContent[$placeholder] = $result;
$placeholderIndex++;
return $placeholder;
},
$escapedContent
);
}
// Second pass: Process BBCodes that do parse inner content
foreach ($parseInner as $bbCode) {
$tag = $bbCode->getTag();
$replacement = $bbCode->getReplacement();
$params = $this->extractParameters($replacement);
$pattern = $this->buildPattern($tag, $params);
$escapedContent = preg_replace_callback(
$pattern,
function ($matches) use ($replacement, $params) {
return $this->replaceBBCode($matches, $replacement, $params);
},
$escapedContent
);
}
// Convert newlines to <br /> tags
$escapedContent = nl2br($escapedContent);
// Restore protected content
foreach ($protectedContent as $placeholder => $content) {
$escapedContent = str_replace($placeholder, $content, $escapedContent);
}
return $escapedContent;
}
/**
* Parse content using all enabled BBCodes from the database
*
* @param string $content The content to parse
* @return string The parsed content with BBCodes replaced by HTML
*/
public function parseWithEnabled(string $content): string {
$bbCodes = $this->bbCodeMapper->findAllEnabled();
return $this->parse($content, $bbCodes);
}
/**
* Extract parameter names from a replacement template
* Returns array of parameter names (excluding 'content')
*
* @param string $replacement The replacement template
* @return array<string> Array of parameter names
*/
private function extractParameters(string $replacement): array {
$params = [];
// Match all {param} patterns
if (preg_match_all('/\{([a-zA-Z0-9_]+)\}/', $replacement, $matches)) {
foreach ($matches[1] as $param) {
if ($param !== 'content') {
$params[] = $param;
}
}
}
return array_unique($params);
}
/**
* Build a regex pattern for matching a BBCode tag with parameters
*
* @param string $tag The BBCode tag name
* @param array<string> $params Array of parameter names
* @return string The regex pattern
*/
private function buildPattern(string $tag, array $params): string {
$escapedTag = preg_quote($tag, '/');
if (empty($params)) {
// Simple tag without parameters: [tag]content[/tag]
return '/\[' . $escapedTag . '\](.*?)\[\/' . $escapedTag . '\]/s';
}
// Tag with parameters: [tag param1="value1" param2="value2"]content[/tag]
// Build pattern to capture each parameter
$paramPattern = '';
foreach ($params as $param) {
$escapedParam = preg_quote($param, '/');
// Match: param="value" or param='value'
$paramPattern .= '(?:.*?' . $escapedParam . '=["\']([^"\']*)["\'])?';
}
return '/\[' . $escapedTag . $paramPattern . '.*?\](.*?)\[\/' . $escapedTag . '\]/s';
}
/**
* Replace a single BBCode match with its HTML replacement
*
* @param array<string> $matches Regex matches
* @param string $replacement The replacement template
* @param array<string> $params Array of parameter names
* @return string The replaced HTML
*/
private function replaceBBCode(array $matches, string $replacement, array $params): string {
// The content is always the last match
$content = end($matches);
// Start with the replacement template
$result = $replacement;
// Replace {content} with the actual content
$result = str_replace('{content}', $content, $result);
// Replace parameter placeholders with their values
foreach ($params as $index => $param) {
// Parameter values are in matches starting from index 1
$value = $matches[$index + 1] ?? '';
$result = str_replace('{' . $param . '}', $value, $result);
}
return $result;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@
"extends @nextcloud/browserslist-config"
],
"dependencies": {
"@nextcloud/auth": "^2.5.3",
"@nextcloud/axios": "^2.5.2",
"@nextcloud/l10n": "^3.4.0",
"@nextcloud/router": "^3.0.1",

3
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@nextcloud/auth':
specifier: ^2.5.3
version: 2.5.3
'@nextcloud/axios':
specifier: ^2.5.2
version: 2.5.2

View File

@@ -20,26 +20,6 @@
<HomeIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.navExamples"
:to="{ path: basePath + '/examples' }"
:active="isPrefixRoute(basePath + '/examples')"
>
<template #icon>
<PuzzleIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.navAbout"
:to="{ path: basePath + '/about' }"
:active="isPrefixRoute(basePath + '/about')"
>
<template #icon>
<InfoIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
<template #footer>
@@ -48,17 +28,19 @@
</NcAppNavigation>
<!-- Main content -->
<NcAppContent id="hello-main">
<header class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted" v-html="strings.subtitle"></p>
</header>
<NcAppContent id="forum-main">
<div id="forum-content">
<header class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted" v-html="strings.subtitle"></p>
</header>
<div id="hello-router">
<div v-if="isRouterLoading" class="router-loading">
<NcLoadingIcon :size="48" />
<div id="forum-router">
<div v-if="isRouterLoading" class="router-loading">
<NcLoadingIcon :size="48" />
</div>
<router-view v-else />
</div>
<router-view v-else />
</div>
</NcAppContent>
</NcContent>
@@ -142,12 +124,18 @@ export default {
</script>
<style scoped lang="scss">
#hello-main {
#forum-main {
height: 100vh;
overflow: auto;
}
#forum-content {
flex-basis: 100%;
flex: 1;
display: flex;
flex-direction: column;
height: 100vh;
/* fills viewport next to sidebar */
overflow: hidden;
max-width: calc(100% - 128px);
margin: 0 auto;
}
.page-header {
@@ -164,7 +152,7 @@ export default {
}
}
#hello-router {
#forum-router {
flex: 1;
overflow-y: auto;
padding: 1rem;

View File

@@ -0,0 +1,122 @@
<template>
<div class="category-card">
<div class="category-header">
<h4 class="category-name">{{ category.name }}</h4>
<div class="category-stats">
<span class="stat">
<span class="stat-value">{{ category.threadCount || 0 }}</span>
<span class="stat-label">{{ strings.threads }}</span>
</span>
<span class="stat-divider">·</span>
<span class="stat">
<span class="stat-value">{{ category.postCount || 0 }}</span>
<span class="stat-label">{{ strings.posts }}</span>
</span>
</div>
</div>
<p v-if="category.description" class="category-description">{{ category.description }}</p>
<p v-else class="category-description muted">{{ strings.noDescription }}</p>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
export default {
name: 'CategoryCard',
props: {
category: {
type: Object,
required: true,
},
},
data() {
return {
strings: {
threads: t('forum', 'Threads'),
posts: t('forum', 'Posts'),
noDescription: t('forum', 'No description available'),
},
}
},
}
</script>
<style scoped lang="scss">
.category-card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
background: var(--color-main-background);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
cursor: pointer;
* {
cursor: inherit;
}
&:hover {
border-color: var(--color-primary-element);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.category-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
gap: 12px;
}
.category-name {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-main-text);
flex: 1;
}
.category-stats {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
white-space: nowrap;
}
.stat {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.stat-value {
font-weight: 600;
color: var(--color-main-text);
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-maxcontrast);
}
.stat-divider {
color: var(--color-text-maxcontrast);
opacity: 0.5;
}
.category-description {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-lighter);
line-height: 1.4;
&.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
font-style: italic;
}
}
}
</style>

203
src/components/PostCard.vue Normal file
View File

@@ -0,0 +1,203 @@
<template>
<div class="post-card" :class="{ 'first-post': isFirstPost }">
<div class="post-header">
<div class="author-info">
<NcAvatar :user="post.authorId" :size="32" />
<div class="author-details">
<span class="author-name">{{ post.authorId }}</span>
<div class="post-meta">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
<span v-if="post.isEdited" class="edited-badge">
<span class="edited-label">{{ strings.edited }}</span>
<NcDateTime v-if="post.editedAt" :timestamp="post.editedAt * 1000" />
</span>
</div>
</div>
</div>
<div class="post-actions">
<NcActions>
<NcActionButton @click="$emit('reply', post)">
<template #icon>
<span class="icon">💬</span>
</template>
{{ strings.reply }}
</NcActionButton>
<NcActionButton v-if="canEdit" @click="$emit('edit', post)">
<template #icon>
<span class="icon"></span>
</template>
{{ strings.edit }}
</NcActionButton>
<NcActionButton v-if="canDelete" @click="$emit('delete', post)">
<template #icon>
<span class="icon">🗑</span>
</template>
{{ strings.delete }}
</NcActionButton>
</NcActions>
</div>
</div>
<div class="post-content">
<div class="content-text" v-html="formattedContent"></div>
</div>
</div>
</template>
<script>
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import { t } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
export default {
name: 'PostCard',
components: {
NcAvatar,
NcDateTime,
NcActions,
NcActionButton,
},
props: {
post: {
type: Object,
required: true,
},
isFirstPost: {
type: Boolean,
default: false,
},
},
emits: ['reply', 'edit', 'delete'],
data() {
return {
strings: {
edited: t('forum', 'Edited'),
reply: t('forum', 'Reply'),
edit: t('forum', 'Edit'),
delete: t('forum', 'Delete'),
},
}
},
computed: {
currentUser() {
return getCurrentUser()
},
canEdit() {
return this.currentUser && this.currentUser.uid === this.post.authorId
},
canDelete() {
// For now, only author can delete. Later add admin/moderator check
return this.currentUser && this.currentUser.uid === this.post.authorId
},
formattedContent() {
// Content is already parsed by BBCodeService on the backend
// BBCodeService handles HTML escaping before parsing BBCodes
return this.post.content
},
},
methods: {
},
}
</script>
<style scoped lang="scss">
.post-card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
background: var(--color-main-background);
transition: box-shadow 0.2s ease;
cursor: pointer;
* {
cursor: inherit;
}
&.first-post {
background: var(--color-background-hover);
border-left: 3px solid var(--color-primary-element);
}
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.post-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
}
.author-info {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
}
.author-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.author-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
}
.post-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
flex-wrap: wrap;
}
.edited-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: var(--color-background-dark);
border-radius: 4px;
font-size: 0.75rem;
}
.edited-label {
font-style: italic;
opacity: 0.8;
}
.post-actions {
flex-shrink: 0;
}
.post-content {
margin-top: 12px;
}
.content-text {
color: var(--color-main-text);
line-height: 1.6;
font-size: 0.95rem;
word-wrap: break-word;
overflow-wrap: break-word;
:deep(br) {
line-height: 1.6;
}
}
.icon {
font-size: 1rem;
}
}
</style>

View File

@@ -0,0 +1,218 @@
<template>
<div class="thread-card" :class="{ pinned: thread.isPinned, locked: thread.isLocked }">
<div class="thread-main">
<div class="thread-header">
<div class="thread-title-row">
<h4 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">📌</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">🔒</span>
{{ thread.title }}
</h4>
</div>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span class="meta-value">{{ thread.authorId }}</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</span>
</div>
</div>
<div class="thread-stats">
<div class="stat">
<span class="stat-icon">💬</span>
<span class="stat-value">{{ thread.postCount || 0 }}</span>
<span class="stat-label">{{ strings.posts }}</span>
</div>
<div class="stat">
<span class="stat-icon">👁</span>
<span class="stat-value">{{ thread.viewCount || 0 }}</span>
<span class="stat-label">{{ strings.views }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import { t } from '@nextcloud/l10n'
export default {
name: 'ThreadCard',
components: {
NcDateTime,
},
props: {
thread: {
type: Object,
required: true,
},
},
data() {
return {
strings: {
by: t('forum', 'by'),
posts: t('forum', 'Posts'),
views: t('forum', 'Views'),
pinned: t('forum', 'Pinned thread'),
locked: t('forum', 'Locked thread'),
},
}
},
}
</script>
<style scoped lang="scss">
.thread-card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
background: var(--color-main-background);
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
cursor: pointer;
* {
cursor: inherit;
}
&:hover {
border-color: var(--color-primary-element);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&.pinned {
background: var(--color-background-hover);
border-color: var(--color-primary-element-light);
}
&.locked {
opacity: 0.85;
}
.thread-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.thread-header {
flex: 1;
min-width: 0;
}
.thread-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.thread-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-main-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
}
.badge {
font-size: 0.9rem;
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.85rem;
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);
}
.meta-divider {
opacity: 0.5;
}
.thread-stats {
display: flex;
/* flex-direction: column; */
gap: 12px;
min-width: 80px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px;
background: var(--color-background-hover);
border-radius: 6px;
}
.stat-icon {
font-size: 1.2rem;
}
.stat-value {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
}
.stat-label {
font-size: 0.7rem;
color: var(--color-text-maxcontrast);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
@media (max-width: 768px) {
.thread-card .thread-main {
flex-direction: column;
}
.thread-stats {
flex-direction: row;
width: 100%;
justify-content: flex-start;
}
}
</style>

View File

@@ -1,9 +1,18 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { generateUrl } from '@nextcloud/router'
const routes: RouteRecordRaw[] = [{ path: '/', component: () => import('@/views/AppView.vue') }]
const routes: RouteRecordRaw[] = [
{ path: '/', component: () => import('@/views/CategoriesView.vue') },
{ path: '/category/:id', component: () => import('@/views/CategoryView.vue') },
{ path: '/c/:slug', component: () => import('@/views/CategoryView.vue') },
{ path: '/thread/:id', component: () => import('@/views/ThreadView.vue') },
{ path: '/t/:slug', component: () => import('@/views/ThreadView.vue') },
// Catch-all route - must be last
{ path: '/:pathMatch(.*)*', component: () => import('@/views/CategoriesView.vue') },
]
const router = createRouter({
history: createWebHashHistory(),
history: createWebHistory(generateUrl('/apps/forum')),
routes,
})

View File

@@ -0,0 +1,177 @@
<template>
<div class="categories-view">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<h2 class="view-title">{{ strings.title }}</h2>
</div>
<div class="toolbar-right">
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
</div>
</div>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Empty state -->
<NcEmptyContent v-else-if="categoryHeaders.length === 0" :title="strings.emptyTitle"
:description="strings.emptyDesc" class="mt-16" />
<!-- Categories list -->
<section v-else class="mt-16">
<div v-for="header in categoryHeaders" :key="header.id" class="header-section">
<h3 class="header-title">{{ header.name }}</h3>
<!-- Categories grid -->
<div v-if="header.categories && header.categories.length > 0" class="categories-grid">
<CategoryCard v-for="category in header.categories" :key="category.id" :category="category"
@click="navigateToCategory(category)" />
</div>
<!-- Empty state for header with no categories -->
<p v-else class="no-categories muted">{{ strings.noCategories }}</p>
</div>
</section>
</div>
</template>
<script>
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import CategoryCard from '@/components/CategoryCard.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
export default {
name: 'CategoriesView',
components: {
NcButton,
NcEmptyContent,
NcLoadingIcon,
CategoryCard,
},
data() {
return {
loading: false,
categoryHeaders: [],
strings: {
title: t('forum', 'Categories'),
refresh: t('forum', 'Refresh'),
loading: t('forum', 'Loading…'),
emptyTitle: t('forum', 'No categories yet'),
emptyDesc: t('forum', 'Categories will appear here once they are created.'),
noCategories: t('forum', 'No categories in this section'),
},
}
},
created() {
this.refresh()
},
methods: {
async refresh() {
try {
this.loading = true
const resp = await ocs.get('/categories')
this.categoryHeaders = resp.data || []
} catch (e) {
console.error('Failed to fetch categories', e)
this.categoryHeaders = []
} finally {
this.loading = false
}
},
navigateToCategory(category) {
this.$router.push(`/c/${category.slug}`)
},
},
}
</script>
<style scoped lang="scss">
.categories-view {
.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;
}
.toolbar {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
}
.view-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.header-section {
margin-bottom: 32px;
&:last-child {
margin-bottom: 0;
}
}
.header-title {
margin: 0 0 16px 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-main-text);
padding-bottom: 8px;
border-bottom: 2px solid var(--color-border);
}
.categories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.no-categories {
padding: 24px;
text-align: center;
font-style: italic;
}
}
</style>

276
src/views/CategoryView.vue Normal file
View File

@@ -0,0 +1,276 @@
<template>
<div class="category-view">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<NcButton type="tertiary" @click="goBack">{{ strings.back }}</NcButton>
</div>
<div class="toolbar-right">
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
<NcButton type="primary" @click="createThread" :disabled="loading || category?.isLocked">
{{ strings.newThread }}
</NcButton>
</div>
</div>
<!-- Category Header -->
<div v-if="category && !loading" class="category-header mt-16">
<h2 class="category-name">{{ category.name }}</h2>
<p v-if="category.description" class="category-description">{{ category.description }}</p>
</div>
<!-- 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">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Empty state -->
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton type="primary" @click="createThread">{{ strings.newThread }}</NcButton>
</template>
</NcEmptyContent>
<!-- Threads list -->
<section v-else class="mt-16">
<div class="threads-list">
<ThreadCard
v-for="thread in sortedThreads"
:key="thread.id"
:thread="thread"
@click="navigateToThread(thread)"
/>
</div>
<!-- Pagination info -->
<div v-if="threads.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingThreads(threads.length) }}</p>
</div>
</section>
</div>
</template>
<script>
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import ThreadCard from '@/components/ThreadCard.vue'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
export default {
name: 'CategoryView',
components: {
NcButton,
NcEmptyContent,
NcLoadingIcon,
ThreadCard,
},
data() {
return {
loading: false,
category: null,
threads: [],
error: null,
limit: 50,
offset: 0,
strings: {
back: t('forum', 'Back'),
refresh: t('forum', 'Refresh'),
newThread: t('forum', 'New Thread'),
loading: t('forum', 'Loading…'),
errorTitle: t('forum', 'Error loading category'),
emptyTitle: t('forum', 'No threads yet'),
emptyDesc: t('forum', 'Be the first to start a discussion in this category.'),
retry: t('forum', 'Retry'),
showingThreads: (count) => n('forum', 'Showing %n thread', 'Showing %n threads', count),
},
}
},
computed: {
categoryId() {
return this.$route.params.id ? parseInt(this.$route.params.id) : null
},
categorySlug() {
return this.$route.params.slug || null
},
sortedThreads() {
// Sort pinned threads first, then by updatedAt descending
return [...this.threads].sort((a, b) => {
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1
}
return b.updatedAt - a.updatedAt
})
},
},
created() {
this.refresh()
},
methods: {
async refresh() {
try {
this.loading = true
this.error = null
// Fetch category details
await this.fetchCategory()
// Fetch threads
if (this.category) {
await this.fetchThreads()
}
} catch (e) {
console.error('Failed to refresh', e)
this.error = e.message || t('forum', 'An unexpected error occurred')
} finally {
this.loading = false
}
},
async fetchCategory() {
try {
let resp
if (this.categorySlug) {
resp = await ocs.get(`/categories/slug/${this.categorySlug}`)
} else if (this.categoryId) {
resp = await ocs.get(`/categories/${this.categoryId}`)
} else {
throw new Error(t('forum', 'No category ID or slug provided'))
}
this.category = resp.data
} catch (e) {
console.error('Failed to fetch category', e)
throw new Error(t('forum', 'Category not found'))
}
},
async fetchThreads() {
try {
const resp = await ocs.get(`/categories/${this.category.id}/threads`, {
params: {
limit: this.limit,
offset: this.offset,
},
})
this.threads = resp.data || []
} catch (e) {
console.error('Failed to fetch threads', e)
throw new Error(t('forum', 'Failed to load threads'))
}
},
navigateToThread(thread) {
this.$router.push(`/t/${thread.slug}`)
},
createThread() {
console.log('Create new thread in category:', this.category?.id)
// Example: this.$router.push({ name: 'new-thread', params: { categoryId: this.category.id } })
},
goBack() {
this.$router.back()
},
},
}
</script>
<style scoped lang="scss">
.category-view {
.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;
}
.toolbar {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
}
.category-header {
padding: 20px;
background: var(--color-background-hover);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.category-name {
margin: 0 0 8px 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-main-text);
}
.category-description {
margin: 0;
font-size: 1rem;
color: var(--color-text-lighter);
line-height: 1.5;
}
.threads-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.pagination-info {
text-align: center;
padding: 12px;
}
}
</style>

380
src/views/ThreadView.vue Normal file
View File

@@ -0,0 +1,380 @@
<template>
<div class="thread-view">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<NcButton type="tertiary" @click="goBack">{{ strings.back }}</NcButton>
</div>
<div class="toolbar-right">
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
<NcButton type="primary" @click="replyToThread" :disabled="loading || thread?.isLocked">
{{ strings.reply }}
</NcButton>
</div>
</div>
<!-- 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">{{ 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">📌</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">🔒</span>
{{ thread.title }}
</h2>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span class="meta-value">{{ 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">👁</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" :post="post" :is-first-post="index === 0"
@reply="handleReply" @edit="handleEdit" @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 type="primary" @click="replyToThread">{{ strings.reply }}</NcButton>
</template>
</NcEmptyContent>
</div>
</template>
<script lang="ts">
import NcButton from '@nextcloud/vue/components/NcButton'
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 { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
export default {
name: 'ThreadView',
components: {
NcButton,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
PostCard,
},
data() {
return {
loading: false,
thread: null,
posts: [],
error: null,
limit: 50,
offset: 0,
strings: {
back: t('forum', 'Back'),
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: string) => n('forum', '%n view', '%n views', count),
pinned: t('forum', 'Pinned thread'),
locked: t('forum', 'Locked thread'),
showingPosts: (count) => n('forum', 'Showing %n post', 'Showing %n posts', count),
},
}
},
computed: {
threadId() {
return this.$route.params.id ? parseInt(this.$route.params.id) : null
},
threadSlug() {
return this.$route.params.slug || null
},
},
created() {
this.refresh()
},
methods: {
async refresh() {
try {
this.loading = true
this.error = null
// Fetch thread details
await this.fetchThread()
// Fetch posts
if (this.thread) {
await this.fetchPosts()
}
} catch (e) {
console.error('Failed to refresh', e)
this.error = e.message || t('forum', 'An unexpected error occurred')
} finally {
this.loading = false
}
},
async fetchThread() {
try {
let resp
if (this.threadSlug) {
resp = await ocs.get(`/threads/slug/${this.threadSlug}`)
} else if (this.threadId) {
resp = await ocs.get(`/threads/${this.threadId}`)
} else {
throw new Error(t('forum', 'No thread ID or slug provided'))
}
this.thread = resp.data
} catch (e) {
console.error('Failed to fetch thread', e)
throw new Error(t('forum', 'Thread not found'))
}
},
async fetchPosts() {
try {
const resp = await ocs.get(`/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()
}
} catch (e) {
console.error('Failed to fetch posts', e)
throw new Error(t('forum', 'Failed to load posts'))
}
},
async markAsRead() {
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) {
console.log('Reply to post:', post.id)
// TODO: Implement reply functionality
// Could open a reply form or navigate to a reply page
},
handleEdit(post) {
console.log('Edit post:', post.id)
// TODO: Implement edit functionality
// Could open an edit dialog or navigate to edit page
},
async handleDelete(post) {
console.log('Delete post:', post.id)
// TODO: Implement delete functionality with confirmation
// if (confirm(t('forum', 'Are you sure you want to delete this post?'))) {
// await ocs.delete(`/posts/${post.id}`)
// await this.refresh()
// }
},
replyToThread() {
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
},
goBack() {
this.$router.back()
},
},
}
</script>
<style scoped lang="scss">
.thread-view {
.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;
}
.toolbar {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
}
.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);
}
.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;
}
}
</style>