mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: bbcode parsing
This commit is contained in:
16
appinfo/routes.php
Normal file
16
appinfo/routes.php
Normal 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' => '.*']],
|
||||
],
|
||||
];
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
189
lib/Service/BBCodeService.php
Normal file
189
lib/Service/BBCodeService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1711
openapi.json
1711
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
56
src/App.vue
56
src/App.vue
@@ -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;
|
||||
|
||||
122
src/components/CategoryCard.vue
Normal file
122
src/components/CategoryCard.vue
Normal 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
203
src/components/PostCard.vue
Normal 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>
|
||||
218
src/components/ThreadCard.vue
Normal file
218
src/components/ThreadCard.vue
Normal 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>
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
177
src/views/CategoriesView.vue
Normal file
177
src/views/CategoriesView.vue
Normal 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
276
src/views/CategoryView.vue
Normal 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
380
src/views/ThreadView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user