mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5391d8fffe | |||
| b0bfbbccdf | |||
| 9525ebfb97 | |||
| 67e9fb9f8c | |||
| a36da9f882 | |||
| c0762158d7 | |||
| 479cdbbba5 | |||
| 255a5cf53d | |||
| feeefa2926 | |||
| f49561ccca | |||
| e59a6f4dc7 | |||
| 9719f518e2 | |||
| 2d10b461c0 | |||
| 2264289b56 | |||
| 3ef545dcc9 | |||
| fb905f8d15 | |||
| 278f1b3cc4 | |||
| 5ee8a16aa1 | |||
| a1671baf2d | |||
| 71ee133ac6 | |||
| 1add8db287 |
@@ -1 +1 @@
|
||||
{".":"0.2.0"}
|
||||
{".":"0.4.0"}
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## [0.4.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.3.0...v0.4.0) (2025-11-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **AppNavigation:** save collapse state to local storage ([a36da9f](https://github.com/chenasraf/nextcloud-forum/commit/a36da9f8822aa6b091e34d82cce8b56a86547b39))
|
||||
* **BBCodeEditor:** add attachment disclaimer ([b0bfbbc](https://github.com/chenasraf/nextcloud-forum/commit/b0bfbbccdf04bd92d374ed31e404c9fadc23f51b))
|
||||
* **BBCodeToolbar:** add emoji picker button ([255a5cf](https://github.com/chenasraf/nextcloud-forum/commit/255a5cf53dcce38c9356b30713a76e95592abe44))
|
||||
* **PostReactions:** use Nextcloud emoji picker ([feeefa2](https://github.com/chenasraf/nextcloud-forum/commit/feeefa2926589cbd0c62053f1700c9bfb6bca545))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile responsiveness ([c076215](https://github.com/chenasraf/nextcloud-forum/commit/c0762158d75e6eebf0ac77a512218cf7b4119a97))
|
||||
* **ProfileView:** mobile responsiveness ([67e9fb9](https://github.com/chenasraf/nextcloud-forum/commit/67e9fb9f8cdb9d1ada660b1d90e8de5aa35051de))
|
||||
* **ThreadCard:** mobile responsiveness ([9525ebf](https://github.com/chenasraf/nextcloud-forum/commit/9525ebfb9705e66281898af7fcb733ba1ae8208c))
|
||||
|
||||
## [0.3.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.1...v0.3.0) (2025-11-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add page header component ([2d10b46](https://github.com/chenasraf/nextcloud-forum/commit/2d10b461c018160d63ed6e63479e1488ba8da38e))
|
||||
* add skeleton component + update categories header ui ([e59a6f4](https://github.com/chenasraf/nextcloud-forum/commit/e59a6f4dc7b60ad0b370b801d541f0007d1896c3))
|
||||
* load forum title/subtitle from public endpoint ([9719f51](https://github.com/chenasraf/nextcloud-forum/commit/9719f518e2b1a9dced781431a6b0d4123aef952c))
|
||||
* user preferences page & auto thread subs pref ([278f1b3](https://github.com/chenasraf/nextcloud-forum/commit/278f1b3cc48b6d2e74c383dec34015e3e3cd1e81))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* user stats post is_first_post counts ([5ee8a16](https://github.com/chenasraf/nextcloud-forum/commit/5ee8a16aa13510c7b6a6b48238bc156c27045e7b))
|
||||
|
||||
## [0.2.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.0...v0.2.1) (2025-11-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* thread card hover styles ([1add8db](https://github.com/chenasraf/nextcloud-forum/commit/1add8db28775d2d13d8b2eb9428a90eb99b32ae8))
|
||||
* unread counts for deleted posts ([71ee133](https://github.com/chenasraf/nextcloud-forum/commit/71ee133ac6b59f9005918594f7e668031b8224fa))
|
||||
|
||||
## [0.2.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.7...v0.2.0) (2025-11-17)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
# Nextcloud Forum
|
||||
|
||||

|
||||
|
||||
A full-featured forum application for Nextcloud, allowing users to create discussion categories,
|
||||
threads, and posts within their Nextcloud instance.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ This app is in early stages of development. While functional, you may encounter
|
||||
|
||||
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
|
||||
]]></description>
|
||||
<version>0.2.0</version>
|
||||
<version>0.4.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
|
||||
<namespace>Forum</namespace>
|
||||
|
||||
@@ -7,7 +7,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Controller;
|
||||
|
||||
use OCA\Forum\AppInfo\Application;
|
||||
use OCA\Forum\Attribute\RequirePermission;
|
||||
use OCA\Forum\Db\CategoryMapper;
|
||||
use OCA\Forum\Db\PostMapper;
|
||||
@@ -21,6 +20,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserManager;
|
||||
use OCP\IUserSession;
|
||||
@@ -41,6 +41,7 @@ class AdminController extends OCSController {
|
||||
private IUserSession $userSession,
|
||||
private IConfig $config,
|
||||
private LoggerInterface $logger,
|
||||
private IL10N $l10n,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
@@ -171,8 +172,8 @@ class AdminController extends OCSController {
|
||||
public function getSettings(): DataResponse {
|
||||
try {
|
||||
$settings = [
|
||||
'title' => $this->config->getAppValue(Application::APP_ID, 'title', 'Forum'),
|
||||
'subtitle' => $this->config->getAppValue(Application::APP_ID, 'subtitle', 'Welcome to the forum'),
|
||||
'title' => $this->config->getSystemValueString('title', $this->l10n->t('Forum')),
|
||||
'subtitle' => $this->config->getSystemValueString('subtitle', $this->l10n->t('Welcome to the forum!')),
|
||||
];
|
||||
|
||||
return new DataResponse($settings);
|
||||
@@ -197,17 +198,17 @@ class AdminController extends OCSController {
|
||||
public function updateSettings(?string $title = null, ?string $subtitle = null): DataResponse {
|
||||
try {
|
||||
if ($title !== null) {
|
||||
$this->config->setAppValue(Application::APP_ID, 'title', $title);
|
||||
$this->config->setSystemValue('title', $title);
|
||||
}
|
||||
|
||||
if ($subtitle !== null) {
|
||||
$this->config->setAppValue(Application::APP_ID, 'subtitle', $subtitle);
|
||||
$this->config->setSystemValue('subtitle', $subtitle);
|
||||
}
|
||||
|
||||
// Return updated settings
|
||||
$settings = [
|
||||
'title' => $this->config->getAppValue(Application::APP_ID, 'title', 'Forum'),
|
||||
'subtitle' => $this->config->getAppValue(Application::APP_ID, 'subtitle', 'Welcome to the forum'),
|
||||
'title' => $this->config->getSystemValueString('title', $this->l10n->t('Forum')),
|
||||
'subtitle' => $this->config->getSystemValueString('subtitle', $this->l10n->t('Welcome to the forum!')),
|
||||
];
|
||||
|
||||
return new DataResponse($settings);
|
||||
|
||||
@@ -367,17 +367,53 @@ class PostController extends OCSController {
|
||||
$post->setUpdatedAt(time());
|
||||
$this->postMapper->update($post);
|
||||
|
||||
// Update thread post count
|
||||
// Update thread post count and lastPostId
|
||||
try {
|
||||
$thread = $this->threadMapper->find($post->getThreadId());
|
||||
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
|
||||
$thread->setUpdatedAt(time());
|
||||
|
||||
// If the deleted post was the last post, update lastPostId to the previous non-deleted post
|
||||
if ($thread->getLastPostId() === $post->getId()) {
|
||||
// Find the latest non-deleted post in this thread (excluding the one being deleted)
|
||||
$latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId());
|
||||
if ($latestPost) {
|
||||
$thread->setLastPostId($latestPost->getId());
|
||||
} else {
|
||||
// No other posts in thread, set to null (or keep first post ID)
|
||||
$thread->setLastPostId(null);
|
||||
}
|
||||
}
|
||||
|
||||
$this->threadMapper->update($thread);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update thread post count after post deletion: ' . $e->getMessage());
|
||||
$this->logger->warning('Failed to update thread after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if thread update fails
|
||||
}
|
||||
|
||||
// Update user stats - decrement post count, and thread count if it's the first post
|
||||
try {
|
||||
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
|
||||
|
||||
// If this is the first post of a thread, also decrement thread count
|
||||
if ($post->getIsFirstPost()) {
|
||||
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if stats update fails
|
||||
}
|
||||
|
||||
// Update category post count
|
||||
try {
|
||||
$category = $this->categoryMapper->find($categoryId);
|
||||
$category->setPostCount(max(0, $category->getPostCount() - 1));
|
||||
$this->categoryMapper->update($category);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category post count after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if category update fails
|
||||
}
|
||||
|
||||
return new DataResponse(['success' => true]);
|
||||
} catch (DoesNotExistException $e) {
|
||||
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);
|
||||
|
||||
57
lib/Controller/SettingsController.php
Normal file
57
lib/Controller/SettingsController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Controller;
|
||||
|
||||
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\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SettingsController extends OCSController {
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private IConfig $config,
|
||||
private LoggerInterface $logger,
|
||||
private IL10N $l10n,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public forum settings (title and subtitle)
|
||||
*
|
||||
* This endpoint is publicly accessible to all users.
|
||||
* For admin-only settings, use AdminController::getSettings()
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{title: string, subtitle: string}, array{}>
|
||||
*
|
||||
* 200: Settings retrieved successfully
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/settings')]
|
||||
public function getPublicSettings(): DataResponse {
|
||||
try {
|
||||
$settings = [
|
||||
'title' => $this->config->getSystemValueString('title', $this->l10n->t('Forum')),
|
||||
'subtitle' => $this->config->getSystemValueString('subtitle', $this->l10n->t('Welcome to the forum!')),
|
||||
];
|
||||
|
||||
return new DataResponse($settings);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching public settings: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch settings'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
use OCA\Forum\Db\UserStatsMapper;
|
||||
use OCA\Forum\Service\UserPreferencesService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
@@ -34,6 +35,7 @@ class ThreadController extends OCSController {
|
||||
private PostMapper $postMapper,
|
||||
private UserStatsMapper $userStatsMapper,
|
||||
private ThreadSubscriptionMapper $threadSubscriptionMapper,
|
||||
private UserPreferencesService $userPreferencesService,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
@@ -246,9 +248,16 @@ class ThreadController extends OCSController {
|
||||
$this->logger->warning('Failed to update user stats: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Auto-subscribe the thread creator to receive notifications
|
||||
// Auto-subscribe the thread creator to receive notifications (if preference is enabled)
|
||||
try {
|
||||
$this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId());
|
||||
$autoSubscribe = $this->userPreferencesService->getPreference(
|
||||
$user->getUID(),
|
||||
UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS
|
||||
);
|
||||
|
||||
if ($autoSubscribe) {
|
||||
$this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to subscribe thread creator: ' . $e->getMessage());
|
||||
}
|
||||
@@ -396,6 +405,18 @@ class ThreadController extends OCSController {
|
||||
// Don't fail the request if category update fails
|
||||
}
|
||||
|
||||
// Update author's user stats (decrement thread count and all posts in this thread)
|
||||
try {
|
||||
$this->userStatsMapper->decrementThreadCount($thread->getAuthorId());
|
||||
// Decrement post count by the number of posts in this thread
|
||||
if ($thread->getPostCount() > 0) {
|
||||
$this->userStatsMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update user stats after thread deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if stats update fails
|
||||
}
|
||||
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'categorySlug' => $categorySlug,
|
||||
|
||||
87
lib/Controller/UserPreferencesController.php
Normal file
87
lib/Controller/UserPreferencesController.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Controller;
|
||||
|
||||
use OCA\Forum\Service\UserPreferencesService;
|
||||
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;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class UserPreferencesController extends OCSController {
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
private UserPreferencesService $preferencesService,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user preferences
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Preferences returned
|
||||
* 401: User not authenticated
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/user-preferences')]
|
||||
public function index(): DataResponse {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$preferences = $this->preferencesService->getAllPreferences($user->getUID());
|
||||
return new DataResponse($preferences);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching user preferences: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch preferences'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preferences
|
||||
*
|
||||
* @param array<string, mixed> $preferences Key-value pairs of preferences to update
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Preferences updated
|
||||
* 400: Invalid preference key or value
|
||||
* 401: User not authenticated
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/user-preferences')]
|
||||
public function update(array $preferences): DataResponse {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$allPreferences = $this->preferencesService->updatePreferences($user->getUID(), $preferences);
|
||||
return new DataResponse($allPreferences);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new DataResponse(
|
||||
['error' => $e->getMessage()],
|
||||
Http::STATUS_BAD_REQUEST
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error updating user preferences: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to update preferences'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,22 +87,26 @@ class PostMapper extends QBMapper {
|
||||
public function findByAuthorId(string $authorId, int $limit = 50, int $offset = 0, bool $excludeFirstPosts = false): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
$qb->select('p.*')
|
||||
->from($this->getTableName(), 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where(
|
||||
$qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
|
||||
$qb->expr()->eq('p.author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
$qb->expr()->isNull('p.deleted_at')
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
);
|
||||
|
||||
if ($excludeFirstPosts) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
|
||||
$qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
|
||||
);
|
||||
}
|
||||
|
||||
$qb->orderBy('created_at', 'DESC')
|
||||
$qb->orderBy('p.created_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
return $this->findEntities($qb);
|
||||
@@ -114,12 +118,16 @@ class PostMapper extends QBMapper {
|
||||
public function findAll(): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
$qb->select('p.*')
|
||||
->from($this->getTableName(), 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
$qb->expr()->isNull('p.deleted_at')
|
||||
)
|
||||
->orderBy('created_at', 'DESC');
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
)
|
||||
->orderBy('p.created_at', 'DESC');
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
@@ -129,9 +137,13 @@ class PostMapper extends QBMapper {
|
||||
public function countAll(): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName())
|
||||
->from($this->getTableName(), 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
$qb->expr()->isNull('p.deleted_at')
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
@@ -145,10 +157,14 @@ class PostMapper extends QBMapper {
|
||||
public function countSince(int $timestamp): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
|
||||
->from($this->getTableName(), 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where($qb->expr()->gte('p.created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
$qb->expr()->isNull('p.deleted_at')
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
@@ -156,6 +172,34 @@ class PostMapper extends QBMapper {
|
||||
return (int)($row['count'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest non-deleted post in a thread, excluding a specific post ID
|
||||
*
|
||||
* @param int $threadId Thread ID
|
||||
* @param int|null $excludePostId Post ID to exclude (typically the one being deleted)
|
||||
* @return Post|null Latest post or null if no posts found
|
||||
*/
|
||||
public function findLatestByThreadId(int $threadId, ?int $excludePostId = null): ?Post {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->isNull('deleted_at'));
|
||||
|
||||
if ($excludePostId !== null) {
|
||||
$qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($excludePostId, IQueryBuilder::PARAM_INT)));
|
||||
}
|
||||
|
||||
$qb->orderBy('created_at', 'DESC')
|
||||
->setMaxResults(1);
|
||||
|
||||
try {
|
||||
return $this->findEntity($qb);
|
||||
} catch (DoesNotExistException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unread posts in a thread after a specific post ID
|
||||
*
|
||||
|
||||
142
lib/Service/UserPreferencesService.php
Normal file
142
lib/Service/UserPreferencesService.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?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\AppInfo\Application;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class UserPreferencesService {
|
||||
/** Preference key for auto-subscribing to created threads */
|
||||
public const PREF_AUTO_SUBSCRIBE_CREATED_THREADS = 'auto_subscribe_created_threads';
|
||||
|
||||
/** @var array<string, mixed> Default preference values */
|
||||
private const DEFAULTS = [
|
||||
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => true,
|
||||
];
|
||||
|
||||
/** @var array<string> List of valid preference keys */
|
||||
private const VALID_KEYS = [
|
||||
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user preferences
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @return array<string, mixed> All user preferences
|
||||
*/
|
||||
public function getAllPreferences(string $userId): array {
|
||||
$preferences = [];
|
||||
|
||||
foreach (self::VALID_KEYS as $key) {
|
||||
$preferences[$key] = $this->getPreference($userId, $key);
|
||||
}
|
||||
|
||||
return $preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single user preference
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param string $key The preference key
|
||||
* @return mixed The preference value
|
||||
* @throws \InvalidArgumentException If the preference key is invalid
|
||||
*/
|
||||
public function getPreference(string $userId, string $key): mixed {
|
||||
if (!in_array($key, self::VALID_KEYS, true)) {
|
||||
throw new \InvalidArgumentException("Invalid preference key: $key");
|
||||
}
|
||||
|
||||
$default = self::DEFAULTS[$key] ?? null;
|
||||
$value = $this->config->getUserValue($userId, Application::APP_ID, $key, $default);
|
||||
|
||||
return $this->parseValue($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple user preferences
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param array<string, mixed> $preferences Key-value pairs of preferences to update
|
||||
* @return array<string, mixed> All user preferences after update
|
||||
* @throws \InvalidArgumentException If any preference key is invalid
|
||||
*/
|
||||
public function updatePreferences(string $userId, array $preferences): array {
|
||||
// Validate all keys before updating
|
||||
foreach ($preferences as $key => $value) {
|
||||
if (!in_array($key, self::VALID_KEYS, true)) {
|
||||
throw new \InvalidArgumentException("Invalid preference key: $key");
|
||||
}
|
||||
}
|
||||
|
||||
// Update each preference
|
||||
foreach ($preferences as $key => $value) {
|
||||
$this->setPreference($userId, $key, $value);
|
||||
}
|
||||
|
||||
// Return all preferences after update
|
||||
return $this->getAllPreferences($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a single user preference
|
||||
*
|
||||
* @param string $userId The user ID
|
||||
* @param string $key The preference key
|
||||
* @param mixed $value The preference value
|
||||
* @throws \InvalidArgumentException If the preference key is invalid
|
||||
*/
|
||||
public function setPreference(string $userId, string $key, mixed $value): void {
|
||||
if (!in_array($key, self::VALID_KEYS, true)) {
|
||||
throw new \InvalidArgumentException("Invalid preference key: $key");
|
||||
}
|
||||
|
||||
$stringValue = $this->stringifyValue($value);
|
||||
$this->config->setUserValue($userId, Application::APP_ID, $key, $stringValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string value back to its proper type
|
||||
*
|
||||
* @param mixed $value The value to parse
|
||||
* @return mixed The parsed value
|
||||
*/
|
||||
private function parseValue(mixed $value): mixed {
|
||||
if ($value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if ($value === 'false') {
|
||||
return false;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return strpos($value, '.') !== false ? (float)$value : (int)$value;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to string for storage
|
||||
*
|
||||
* @param mixed $value The value to stringify
|
||||
* @return string The stringified value
|
||||
*/
|
||||
private function stringifyValue(mixed $value): string {
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'true' : 'false';
|
||||
}
|
||||
return (string)$value;
|
||||
}
|
||||
}
|
||||
@@ -56,35 +56,8 @@ class UserStatsService {
|
||||
* @return array{users: int, updated: int, created: int} Statistics about the rebuild
|
||||
*/
|
||||
public function rebuildAllUserStats(): array {
|
||||
// Get all users who have posted or created threads
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->selectDistinct('author_id')
|
||||
->from('forum_posts')
|
||||
->where($qb->expr()->isNotNull('author_id'));
|
||||
$result = $qb->executeQuery();
|
||||
$users = [];
|
||||
while ($row = $result->fetch()) {
|
||||
$users[] = $row['author_id'];
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
$updated = 0;
|
||||
$created = 0;
|
||||
|
||||
foreach ($users as $userId) {
|
||||
$wasCreated = $this->rebuildUserStats($userId);
|
||||
if ($wasCreated) {
|
||||
$created++;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'users' => count($users),
|
||||
'updated' => $updated,
|
||||
'created' => $created,
|
||||
];
|
||||
// Delegate to createStatsForAllUsers which processes all Nextcloud users
|
||||
return $this->createStatsForAllUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,30 +67,39 @@ class UserStatsService {
|
||||
* @return bool True if stats were created, false if they were updated
|
||||
*/
|
||||
public function rebuildUserStats(string $userId): bool {
|
||||
// Count threads created by this user
|
||||
// Count non-deleted threads created by this user
|
||||
$threadQb = $this->db->getQueryBuilder();
|
||||
$threadQb->select($threadQb->func()->count('*', 'count'))
|
||||
->from('forum_threads')
|
||||
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)));
|
||||
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)))
|
||||
->andWhere($threadQb->expr()->isNull('deleted_at'));
|
||||
$threadResult = $threadQb->executeQuery();
|
||||
$threadCount = (int)($threadResult->fetchOne() ?? 0);
|
||||
$threadResult->closeCursor();
|
||||
|
||||
// Count posts created by this user
|
||||
// Count non-deleted posts created by this user (from non-deleted threads)
|
||||
// Exclude is_first_post posts as they are counted as threads
|
||||
$postQb = $this->db->getQueryBuilder();
|
||||
$postQb->select($postQb->func()->count('*', 'count'))
|
||||
->from('forum_posts')
|
||||
->where($postQb->expr()->eq('author_id', $postQb->createNamedParameter($userId)));
|
||||
->from('forum_posts', 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where($postQb->expr()->eq('p.author_id', $postQb->createNamedParameter($userId)))
|
||||
->andWhere($postQb->expr()->isNull('p.deleted_at'))
|
||||
->andWhere($postQb->expr()->isNull('t.deleted_at'))
|
||||
->andWhere($postQb->expr()->eq('p.is_first_post', $postQb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)));
|
||||
$postResult = $postQb->executeQuery();
|
||||
$postCount = (int)($postResult->fetchOne() ?? 0);
|
||||
$postResult->closeCursor();
|
||||
|
||||
// Get the timestamp of the last post
|
||||
// Get the timestamp of the last non-deleted post (from non-deleted threads)
|
||||
$lastPostQb = $this->db->getQueryBuilder();
|
||||
$lastPostQb->select('created_at')
|
||||
->from('forum_posts')
|
||||
->where($lastPostQb->expr()->eq('author_id', $lastPostQb->createNamedParameter($userId)))
|
||||
->orderBy('created_at', 'DESC')
|
||||
$lastPostQb->select('p.created_at')
|
||||
->from('forum_posts', 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $lastPostQb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where($lastPostQb->expr()->eq('p.author_id', $lastPostQb->createNamedParameter($userId)))
|
||||
->andWhere($lastPostQb->expr()->isNull('p.deleted_at'))
|
||||
->andWhere($lastPostQb->expr()->isNull('t.deleted_at'))
|
||||
->orderBy('p.created_at', 'DESC')
|
||||
->setMaxResults(1);
|
||||
$lastPostResult = $lastPostQb->executeQuery();
|
||||
$lastPostAt = $lastPostResult->fetchOne();
|
||||
|
||||
414
openapi.json
414
openapi.json
@@ -6630,6 +6630,108 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/settings": {
|
||||
"get": {
|
||||
"operationId": "settings-get-public-settings",
|
||||
"summary": "Get public forum settings (title and subtitle)",
|
||||
"description": "This endpoint is publicly accessible to all users. For admin-only settings, use AdminController::getSettings()",
|
||||
"tags": [
|
||||
"settings"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Settings retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title",
|
||||
"subtitle"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/threads": {
|
||||
"get": {
|
||||
"operationId": "thread-index",
|
||||
@@ -8199,6 +8301,318 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/user-preferences": {
|
||||
"get": {
|
||||
"operationId": "user_preferences-index",
|
||||
"summary": "Get all user preferences",
|
||||
"tags": [
|
||||
"user_preferences"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Preferences returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "User not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"operationId": "user_preferences-update",
|
||||
"summary": "Update user preferences",
|
||||
"tags": [
|
||||
"user_preferences"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"preferences"
|
||||
],
|
||||
"properties": {
|
||||
"preferences": {
|
||||
"type": "object",
|
||||
"description": "Key-value pairs of preferences to update",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Preferences updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "User not authenticated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid preference key or value",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/users/{userId}/roles": {
|
||||
"get": {
|
||||
"operationId": "user_role-by-user",
|
||||
|
||||
@@ -103,6 +103,10 @@ export default defineComponent({
|
||||
padding: 1rem;
|
||||
min-height: 0;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-spacer {
|
||||
@@ -115,6 +119,7 @@ export default defineComponent({
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin-top: 128px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navSearch"
|
||||
:to="{ path: '/search' }"
|
||||
:active="isSearchActive"
|
||||
:active="isPathActive('/search')"
|
||||
>
|
||||
<template #icon>
|
||||
<MagnifyIcon :size="20" />
|
||||
@@ -55,6 +55,17 @@
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<!-- Preferences menu item -->
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navPreferences"
|
||||
:to="{ path: '/preferences' }"
|
||||
:active="isPathActive('/preferences')"
|
||||
>
|
||||
<template #icon>
|
||||
<AccountCogIcon :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<!-- Admin menu item - only visible to admins -->
|
||||
@@ -80,7 +91,7 @@
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navAdminDashboard"
|
||||
:to="{ path: '/admin' }"
|
||||
:active="isAdminDashboardActive"
|
||||
:active="isPathActive('/admin')"
|
||||
>
|
||||
<template #icon>
|
||||
<ChartLineIcon :size="20" />
|
||||
@@ -90,7 +101,7 @@
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navAdminSettings"
|
||||
:to="{ path: '/admin/settings' }"
|
||||
:active="isAdminSettingsActive"
|
||||
:active="isPathActive('/admin/settings')"
|
||||
>
|
||||
<template #icon>
|
||||
<CogIcon :size="20" />
|
||||
@@ -100,7 +111,7 @@
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navAdminUsers"
|
||||
:to="{ path: '/admin/users' }"
|
||||
:active="isAdminUsersActive"
|
||||
:active="isPathActive('/admin/users', true)"
|
||||
>
|
||||
<template #icon>
|
||||
<AccountMultipleIcon :size="20" />
|
||||
@@ -110,7 +121,7 @@
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navAdminRoles"
|
||||
:to="{ path: '/admin/roles' }"
|
||||
:active="isAdminRolesActive"
|
||||
:active="isPathActive('/admin/roles', true)"
|
||||
>
|
||||
<template #icon>
|
||||
<ShieldAccountIcon :size="20" />
|
||||
@@ -120,7 +131,7 @@
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navAdminCategories"
|
||||
:to="{ path: '/admin/categories' }"
|
||||
:active="isAdminCategoriesActive"
|
||||
:active="isPathActive('/admin/categories', true)"
|
||||
>
|
||||
<template #icon>
|
||||
<FolderIcon :size="20" />
|
||||
@@ -130,7 +141,7 @@
|
||||
<NcAppNavigationItem
|
||||
:name="strings.navAdminBBCodes"
|
||||
:to="{ path: '/admin/bbcodes' }"
|
||||
:active="isAdminBBCodesActive"
|
||||
:active="isPathActive('/admin/bbcodes', true)"
|
||||
>
|
||||
<template #icon>
|
||||
<CodeBracketsIcon :size="20" />
|
||||
@@ -168,6 +179,7 @@ import ChartLineIcon from '@icons/ChartLine.vue'
|
||||
import AccountMultipleIcon from '@icons/AccountMultiple.vue'
|
||||
import CodeBracketsIcon from '@icons/CodeBrackets.vue'
|
||||
import CogIcon from '@icons/Cog.vue'
|
||||
import AccountCogIcon from '@icons/AccountCog.vue'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
import { useCurrentUser } from '@/composables/useCurrentUser'
|
||||
import { useUserRole } from '@/composables/useUserRole'
|
||||
@@ -194,6 +206,7 @@ export default defineComponent({
|
||||
AccountMultipleIcon,
|
||||
CodeBracketsIcon,
|
||||
CogIcon,
|
||||
AccountCogIcon,
|
||||
},
|
||||
setup() {
|
||||
const { categoryHeaders, fetchCategories } = useCategories()
|
||||
@@ -224,13 +237,15 @@ export default defineComponent({
|
||||
searchValue: '',
|
||||
openHeaders: {} as Record<number, boolean>,
|
||||
isAdminOpen: true,
|
||||
STORAGE_KEY: 'forum_navigation_state',
|
||||
strings: {
|
||||
searchLabel: t('forum', 'Search'),
|
||||
navHome: t('forum', 'Home'),
|
||||
navSearch: t('forum', 'Search'),
|
||||
navPreferences: t('forum', 'User Preferences'),
|
||||
navAdmin: t('forum', 'Admin'),
|
||||
navAdminDashboard: t('forum', 'Dashboard'),
|
||||
navAdminSettings: t('forum', 'Settings'),
|
||||
navAdminSettings: t('forum', 'Forum Settings'),
|
||||
navAdminUsers: t('forum', 'Users'),
|
||||
navAdminRoles: t('forum', 'Roles'),
|
||||
navAdminCategories: t('forum', 'Categories'),
|
||||
@@ -240,50 +255,80 @@ export default defineComponent({
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSearchActive(): boolean {
|
||||
return this.$route.path === '/search'
|
||||
},
|
||||
isAdminDashboardActive(): boolean {
|
||||
return this.$route.path === '/admin'
|
||||
},
|
||||
isAdminSettingsActive(): boolean {
|
||||
return this.$route.path === '/admin/settings'
|
||||
},
|
||||
isAdminUsersActive(): boolean {
|
||||
return this.$route.path.startsWith('/admin/users')
|
||||
},
|
||||
isAdminRolesActive(): boolean {
|
||||
return this.$route.path.startsWith('/admin/roles')
|
||||
},
|
||||
isAdminCategoriesActive(): boolean {
|
||||
return this.$route.path.startsWith('/admin/categories')
|
||||
},
|
||||
isAdminBBCodesActive(): boolean {
|
||||
return this.$route.path.startsWith('/admin/bbcodes')
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
// Fetch categories for sidebar
|
||||
try {
|
||||
await this.fetchCategories()
|
||||
|
||||
// Initialize all headers as open by default
|
||||
const openState: Record<number, boolean> = {}
|
||||
this.categoryHeaders.forEach((header) => {
|
||||
openState[header.id] = true
|
||||
})
|
||||
this.openHeaders = openState
|
||||
// Load saved state from local storage
|
||||
this.loadNavigationState()
|
||||
} catch (e) {
|
||||
console.error('Failed to load categories for sidebar:', e)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadNavigationState(): void {
|
||||
try {
|
||||
const savedState = localStorage.getItem(this.STORAGE_KEY)
|
||||
if (savedState) {
|
||||
const parsed = JSON.parse(savedState)
|
||||
|
||||
// Load admin section state
|
||||
if (typeof parsed.isAdminOpen === 'boolean') {
|
||||
this.isAdminOpen = parsed.isAdminOpen
|
||||
}
|
||||
|
||||
// Load category headers state
|
||||
if (parsed.openHeaders && typeof parsed.openHeaders === 'object') {
|
||||
this.openHeaders = parsed.openHeaders
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize headers that don't have saved state to open by default
|
||||
const openState: Record<number, boolean> = { ...this.openHeaders }
|
||||
this.categoryHeaders.forEach((header) => {
|
||||
if (openState[header.id] === undefined) {
|
||||
openState[header.id] = true
|
||||
}
|
||||
})
|
||||
this.openHeaders = openState
|
||||
} catch (e) {
|
||||
console.error('Failed to load navigation state from local storage:', e)
|
||||
|
||||
// Fallback: Initialize all headers as open by default
|
||||
const openState: Record<number, boolean> = {}
|
||||
this.categoryHeaders.forEach((header) => {
|
||||
openState[header.id] = true
|
||||
})
|
||||
this.openHeaders = openState
|
||||
}
|
||||
},
|
||||
|
||||
saveNavigationState(): void {
|
||||
try {
|
||||
const state = {
|
||||
isAdminOpen: this.isAdminOpen,
|
||||
openHeaders: this.openHeaders,
|
||||
}
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state))
|
||||
} catch (e) {
|
||||
console.error('Failed to save navigation state to local storage:', e)
|
||||
}
|
||||
},
|
||||
|
||||
isPathActive(path: string, usePrefix = false): boolean {
|
||||
if (usePrefix) {
|
||||
return this.$route.path.startsWith(path)
|
||||
}
|
||||
return this.$route.path === path
|
||||
},
|
||||
|
||||
toggleHeader(headerId: number): void {
|
||||
this.openHeaders = {
|
||||
...this.openHeaders,
|
||||
[headerId]: !this.openHeaders[headerId],
|
||||
}
|
||||
this.saveNavigationState()
|
||||
},
|
||||
|
||||
isHeaderOpen(headerId: number): boolean {
|
||||
@@ -292,6 +337,7 @@ export default defineComponent({
|
||||
|
||||
toggleAdmin(): void {
|
||||
this.isAdminOpen = !this.isAdminOpen
|
||||
this.saveNavigationState()
|
||||
},
|
||||
|
||||
isCategoryActive(category: Category): boolean {
|
||||
|
||||
@@ -22,7 +22,6 @@ export default defineComponent({
|
||||
.app-toolbar {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
@@ -33,15 +32,22 @@ export default defineComponent({
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 200px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding-left: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,18 +11,24 @@
|
||||
class="bbcode-editor-textarea"
|
||||
ref="textarea"
|
||||
/>
|
||||
<NcNoteCard v-if="hasAttachmentBBCode" type="warning" class="attachment-disclaimer">
|
||||
<span v-html="strings.attachmentDisclaimer"></span>
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import BBCodeToolbar from './BBCodeToolbar.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BBCodeEditor',
|
||||
components: {
|
||||
NcTextArea,
|
||||
NcNoteCard,
|
||||
BBCodeToolbar,
|
||||
},
|
||||
props: {
|
||||
@@ -51,8 +57,21 @@ export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
textareaElement: null as HTMLTextAreaElement | null,
|
||||
strings: {
|
||||
attachmentDisclaimer: t(
|
||||
'forum',
|
||||
"{bStart}Please note:{bEnd} Attached files will be visible to anyone in the forum, regardless of the file's sharing settings.",
|
||||
{ bStart: '<strong>', bEnd: '</strong>' },
|
||||
{ escape: false },
|
||||
),
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasAttachmentBBCode(): boolean {
|
||||
return /\[attachment[^\]]*\]/i.test(this.modelValue)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateTextareaRef()
|
||||
},
|
||||
@@ -102,4 +121,8 @@ export default defineComponent({
|
||||
height: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-disclaimer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,12 +14,25 @@
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<NcEmojiPicker @select="handleEmojiSelect">
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="strings.emojiLabel"
|
||||
:title="strings.emojiLabel"
|
||||
class="bbcode-button"
|
||||
>
|
||||
<template #icon>
|
||||
<EmoticonIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</NcEmojiPicker>
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="helpLabel"
|
||||
:title="helpLabel"
|
||||
:aria-label="strings.helpLabel"
|
||||
:title="strings.helpLabel"
|
||||
@click="showHelp = true"
|
||||
class="bbcode-button bbcode-help-button"
|
||||
>
|
||||
@@ -36,6 +49,7 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker'
|
||||
import { getFilePickerBuilder } from '@nextcloud/dialogs'
|
||||
import FormatBoldIcon from '@icons/FormatBold.vue'
|
||||
import FormatItalicIcon from '@icons/FormatItalic.vue'
|
||||
@@ -56,6 +70,7 @@ import FormatAlignRightIcon from '@icons/FormatAlignRight.vue'
|
||||
import EyeOffIcon from '@icons/EyeOff.vue'
|
||||
import FormatListBulletedIcon from '@icons/FormatListBulleted.vue'
|
||||
import PaperclipIcon from '@icons/Paperclip.vue'
|
||||
import EmoticonIcon from '@icons/Emoticon.vue'
|
||||
import HelpCircleIcon from '@icons/HelpCircle.vue'
|
||||
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
@@ -76,7 +91,9 @@ export default defineComponent({
|
||||
name: 'BBCodeToolbar',
|
||||
components: {
|
||||
NcButton,
|
||||
NcEmojiPicker,
|
||||
BBCodeHelpDialog,
|
||||
EmoticonIcon,
|
||||
HelpCircleIcon,
|
||||
},
|
||||
props: {
|
||||
@@ -89,12 +106,13 @@ export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
showHelp: false,
|
||||
strings: {
|
||||
helpLabel: t('forum', 'BBCode Help'),
|
||||
emojiLabel: t('forum', 'Insert emoji'),
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
helpLabel(): string {
|
||||
return t('forum', 'BBCode Help')
|
||||
},
|
||||
bbcodeButtons(): BBCodeButton[] {
|
||||
return [
|
||||
{
|
||||
@@ -366,6 +384,34 @@ export default defineComponent({
|
||||
// Otherwise, user simply canceled - no need to log
|
||||
}
|
||||
},
|
||||
|
||||
handleEmojiSelect(emoji: string): void {
|
||||
if (!this.textareaRef) {
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = this.textareaRef
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const beforeText = textarea.value.substring(0, start)
|
||||
const afterText = textarea.value.substring(end)
|
||||
|
||||
const newText = beforeText + emoji + afterText
|
||||
const cursorPos = beforeText.length + emoji.length
|
||||
|
||||
// Emit the insert event so the parent can update the model
|
||||
this.$emit('insert', {
|
||||
text: newText,
|
||||
cursorPos,
|
||||
selectedText: '',
|
||||
})
|
||||
|
||||
// Focus the textarea after insertion
|
||||
this.$nextTick(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursorPos, cursorPos)
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
75
src/components/PageHeader.vue
Normal file
75
src/components/PageHeader.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<template v-if="loading">
|
||||
<Skeleton width="200px" height="1lh" radius="6px" />
|
||||
<Skeleton width="350px" height="1lh" radius="4px" class="mt-8" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="page-title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import Skeleton from './Skeleton.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageHeader',
|
||||
components: {
|
||||
Skeleton,
|
||||
},
|
||||
props: {
|
||||
/**
|
||||
* The main title/heading
|
||||
*/
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/**
|
||||
* Optional subtitle/description
|
||||
*/
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/**
|
||||
* Show loading skeleton
|
||||
*/
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header {
|
||||
padding: 20px;
|
||||
background: var(--color-background-hover);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
margin-bottom: 16px;
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-lighter);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
58
src/components/PageWrapper.vue
Normal file
58
src/components/PageWrapper.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="page-wrapper-container">
|
||||
<!-- Toolbar slot - always full width -->
|
||||
<div v-if="$slots.toolbar" class="page-wrapper-toolbar">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
|
||||
<!-- Content wrapper - respects fullWidth prop -->
|
||||
<div class="page-wrapper-content" :class="{ 'full-width': fullWidth }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageWrapper',
|
||||
props: {
|
||||
/**
|
||||
* Whether to use full width or fixed width (900px max with auto margins)
|
||||
*/
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-wrapper-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-wrapper-toolbar {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-wrapper-content {
|
||||
padding: 16px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
|
||||
&.full-width {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,48 +14,11 @@
|
||||
</button>
|
||||
|
||||
<!-- Add custom reaction button -->
|
||||
<div class="add-reaction">
|
||||
<button
|
||||
class="add-reaction-button"
|
||||
:class="{ open: showPicker }"
|
||||
:title="strings.addReaction"
|
||||
@click="togglePicker"
|
||||
>
|
||||
<NcEmojiPicker @select="handleSelectEmoji" style="display: inline-block">
|
||||
<button class="add-reaction-button" :title="strings.addReaction">
|
||||
<span class="icon">+</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emoji picker (teleported to body for proper fixed positioning) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showPicker" class="emoji-picker-overlay" @click="closePicker">
|
||||
<div class="emoji-picker-container" @click.stop>
|
||||
<button class="emoji-picker-close" :title="strings.close" @click="closePicker">
|
||||
<Close :size="20" />
|
||||
</button>
|
||||
<div class="emoji-picker-content">
|
||||
<h3>{{ strings.pickEmoji }}</h3>
|
||||
<div class="emoji-categories">
|
||||
<div v-for="group in emojiGroups" :key="group.name" class="emoji-category">
|
||||
<h4 class="category-header">{{ group.name }}</h4>
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="item in group.emojis"
|
||||
:key="item.emoji"
|
||||
class="emoji-option"
|
||||
:title="item.title"
|
||||
@click="handleSelectEmoji(item.emoji)"
|
||||
>
|
||||
{{ item.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</NcEmojiPicker>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -64,13 +27,12 @@ import { defineComponent, type PropType } from 'vue'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { useReactions, type ReactionGroup } from '@/composables/useReactions'
|
||||
import { EMOJI_GROUPS } from '@/constants/emojis'
|
||||
import Close from '@icons/Close.vue'
|
||||
import NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PostReactions',
|
||||
components: {
|
||||
Close,
|
||||
NcEmojiPicker,
|
||||
},
|
||||
props: {
|
||||
postId: {
|
||||
@@ -91,13 +53,9 @@ export default defineComponent({
|
||||
return {
|
||||
defaultEmojis: ['👍', '❤️', '😄', '🎉', '👏'],
|
||||
reactionGroups: [...this.reactions] as ReactionGroup[],
|
||||
showPicker: false,
|
||||
strings: {
|
||||
addReaction: t('forum', 'Add reaction'),
|
||||
pickEmoji: t('forum', 'Pick an emoji'),
|
||||
close: t('forum', 'Close'),
|
||||
},
|
||||
emojiGroups: EMOJI_GROUPS,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -153,25 +111,8 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
togglePicker() {
|
||||
this.showPicker = !this.showPicker
|
||||
},
|
||||
closePicker() {
|
||||
this.showPicker = false
|
||||
},
|
||||
handleSelectEmoji(emoji: string) {
|
||||
this.handleToggleReaction(emoji)
|
||||
this.closePicker()
|
||||
},
|
||||
getEmojiTitle(emoji: string): string | null {
|
||||
// Find the emoji title from the emoji groups
|
||||
for (const group of this.emojiGroups) {
|
||||
const item = group.emojis.find((e) => e.emoji === emoji)
|
||||
if (item) {
|
||||
return item.title
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
getCount(emoji: string): number {
|
||||
const group = this.reactionGroups.find((g) => g.emoji === emoji)
|
||||
@@ -229,28 +170,27 @@ export default defineComponent({
|
||||
getReactionTooltip(emoji: string): string {
|
||||
const count = this.getCount(emoji)
|
||||
const hasReacted = this.isReacted(emoji)
|
||||
const title = this.getEmojiTitle(emoji) ?? emoji
|
||||
|
||||
if (count === 0) {
|
||||
return t('forum', 'React with {title}', { title })
|
||||
return t('forum', 'React with {emoji}', { emoji })
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
return hasReacted
|
||||
? t('forum', 'You reacted with {title}', { title })
|
||||
: t('forum', '1 person reacted with {title}', { title })
|
||||
? t('forum', 'You reacted with {emoji}', { emoji })
|
||||
: t('forum', '1 person reacted with {emoji}', { emoji })
|
||||
}
|
||||
|
||||
return hasReacted
|
||||
? n(
|
||||
'forum',
|
||||
'You and %n other reacted with {title}',
|
||||
'You and %n others reacted with {title}',
|
||||
'You and %n other reacted with {emoji}',
|
||||
'You and %n others reacted with {emoji}',
|
||||
count - 1,
|
||||
{ title },
|
||||
{ emoji },
|
||||
)
|
||||
: n('forum', '%n person reacted with {title}', '%n people reacted with {title}', count, {
|
||||
title,
|
||||
: n('forum', '%n person reacted with {emoji}', '%n people reacted with {emoji}', count, {
|
||||
emoji,
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -324,203 +264,40 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
.add-reaction {
|
||||
position: relative;
|
||||
.add-reaction-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.add-reaction-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-border-dark);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-primary-element);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
&:hover .icon,
|
||||
&.open .icon {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transition animations
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Emoji picker overlay - not scoped because it's teleported to body
|
||||
.emoji-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.emoji-picker-container {
|
||||
background: var(--color-main-background);
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
opacity: 0.6;
|
||||
|
||||
.emoji-picker-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-maxcontrast);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-border-dark);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.emoji-picker-content {
|
||||
padding: 20px;
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
.icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.emoji-categories {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--color-background-dark);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-dark);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-category {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-maxcontrast);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 4px;
|
||||
|
||||
.emoji-option {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-border);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover .icon {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
99
src/components/Skeleton.vue
Normal file
99
src/components/Skeleton.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="skeleton" :style="skeletonStyle"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Skeleton',
|
||||
props: {
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '20px',
|
||||
},
|
||||
shape: {
|
||||
type: String as () => 'circle' | 'square' | 'rounded-rect',
|
||||
default: 'rounded-rect',
|
||||
validator: (value: string) => ['circle', 'square', 'rounded-rect'].includes(value),
|
||||
},
|
||||
radius: {
|
||||
type: String,
|
||||
default: '4px',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
skeletonStyle() {
|
||||
const borderRadius = this.getBorderRadius()
|
||||
return {
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
borderRadius,
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getBorderRadius(): string {
|
||||
switch (this.shape) {
|
||||
case 'circle':
|
||||
return '50%'
|
||||
case 'square':
|
||||
return '0'
|
||||
case 'rounded-rect':
|
||||
return this.radius
|
||||
default:
|
||||
return this.radius
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
</style>
|
||||
@@ -111,10 +111,11 @@ export default defineComponent({
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&.unread:hover,
|
||||
&.pinned:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
@@ -136,6 +137,11 @@ export default defineComponent({
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.unread-indicator {
|
||||
@@ -211,16 +217,36 @@ export default defineComponent({
|
||||
padding: 8px;
|
||||
background: var(--color-background-hover);
|
||||
border-radius: 6px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: row;
|
||||
padding: 6px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--color-main-text);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@@ -228,6 +254,10 @@ export default defineComponent({
|
||||
color: var(--color-text-maxcontrast);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,8 +268,10 @@ export default defineComponent({
|
||||
|
||||
.thread-stats {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
/**
|
||||
* Emoji groups with names and titles
|
||||
*/
|
||||
|
||||
export interface EmojiItem {
|
||||
emoji: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface EmojiGroup {
|
||||
name: string
|
||||
emojis: EmojiItem[]
|
||||
}
|
||||
|
||||
export const EMOJI_GROUPS: EmojiGroup[] = [
|
||||
{
|
||||
name: t('forum', 'Smileys & Emotion'),
|
||||
emojis: [
|
||||
{ emoji: '😀', title: t('forum', 'Grinning Face') },
|
||||
{ emoji: '😃', title: t('forum', 'Grinning Face with Big Eyes') },
|
||||
{ emoji: '😄', title: t('forum', 'Grinning Face with Smiling Eyes') },
|
||||
{ emoji: '😁', title: t('forum', 'Beaming Face with Smiling Eyes') },
|
||||
{ emoji: '😆', title: t('forum', 'Grinning Squinting Face') },
|
||||
{ emoji: '😅', title: t('forum', 'Grinning Face with Sweat') },
|
||||
{ emoji: '😂', title: t('forum', 'Face with Tears of Joy') },
|
||||
{ emoji: '🤣', title: t('forum', 'Rolling on the Floor Laughing') },
|
||||
{ emoji: '😊', title: t('forum', 'Smiling Face with Smiling Eyes') },
|
||||
{ emoji: '😇', title: t('forum', 'Smiling Face with Halo') },
|
||||
{ emoji: '🙂', title: t('forum', 'Slightly Smiling Face') },
|
||||
{ emoji: '🙃', title: t('forum', 'Upside-Down Face') },
|
||||
{ emoji: '😉', title: t('forum', 'Winking Face') },
|
||||
{ emoji: '😌', title: t('forum', 'Relieved Face') },
|
||||
{ emoji: '😍', title: t('forum', 'Smiling Face with Heart-Eyes') },
|
||||
{ emoji: '🥰', title: t('forum', 'Smiling Face with Hearts') },
|
||||
{ emoji: '😘', title: t('forum', 'Face Blowing a Kiss') },
|
||||
{ emoji: '😗', title: t('forum', 'Kissing Face') },
|
||||
{ emoji: '😙', title: t('forum', 'Kissing Face with Smiling Eyes') },
|
||||
{ emoji: '😚', title: t('forum', 'Kissing Face with Closed Eyes') },
|
||||
{ emoji: '😋', title: t('forum', 'Face Savoring Food') },
|
||||
{ emoji: '😛', title: t('forum', 'Face with Tongue') },
|
||||
{ emoji: '😝', title: t('forum', 'Squinting Face with Tongue') },
|
||||
{ emoji: '😜', title: t('forum', 'Winking Face with Tongue') },
|
||||
{ emoji: '🤪', title: t('forum', 'Zany Face') },
|
||||
{ emoji: '🤨', title: t('forum', 'Face with Raised Eyebrow') },
|
||||
{ emoji: '🧐', title: t('forum', 'Face with Monocle') },
|
||||
{ emoji: '🤓', title: t('forum', 'Nerd Face') },
|
||||
{ emoji: '😎', title: t('forum', 'Smiling Face with Sunglasses') },
|
||||
{ emoji: '🤩', title: t('forum', 'Star-Struck') },
|
||||
{ emoji: '🥳', title: t('forum', 'Partying Face') },
|
||||
{ emoji: '😏', title: t('forum', 'Smirking Face') },
|
||||
{ emoji: '😒', title: t('forum', 'Unamused Face') },
|
||||
{ emoji: '😞', title: t('forum', 'Disappointed Face') },
|
||||
{ emoji: '😔', title: t('forum', 'Pensive Face') },
|
||||
{ emoji: '😟', title: t('forum', 'Worried Face') },
|
||||
{ emoji: '😕', title: t('forum', 'Confused Face') },
|
||||
{ emoji: '🙁', title: t('forum', 'Slightly Frowning Face') },
|
||||
{ emoji: '😣', title: t('forum', 'Persevering Face') },
|
||||
{ emoji: '😖', title: t('forum', 'Confounded Face') },
|
||||
{ emoji: '😫', title: t('forum', 'Tired Face') },
|
||||
{ emoji: '😩', title: t('forum', 'Weary Face') },
|
||||
{ emoji: '🥺', title: t('forum', 'Pleading Face') },
|
||||
{ emoji: '😢', title: t('forum', 'Crying Face') },
|
||||
{ emoji: '😭', title: t('forum', 'Loudly Crying Face') },
|
||||
{ emoji: '😤', title: t('forum', 'Face with Steam From Nose') },
|
||||
{ emoji: '😠', title: t('forum', 'Angry Face') },
|
||||
{ emoji: '😡', title: t('forum', 'Enraged Face') },
|
||||
{ emoji: '🤬', title: t('forum', 'Face with Symbols on Mouth') },
|
||||
{ emoji: '🤯', title: t('forum', 'Exploding Head') },
|
||||
{ emoji: '😳', title: t('forum', 'Flushed Face') },
|
||||
{ emoji: '🥵', title: t('forum', 'Hot Face') },
|
||||
{ emoji: '🥶', title: t('forum', 'Cold Face') },
|
||||
{ emoji: '😱', title: t('forum', 'Face Screaming in Fear') },
|
||||
{ emoji: '😨', title: t('forum', 'Fearful Face') },
|
||||
{ emoji: '😰', title: t('forum', 'Anxious Face with Sweat') },
|
||||
{ emoji: '😥', title: t('forum', 'Sad but Relieved Face') },
|
||||
{ emoji: '😓', title: t('forum', 'Downcast Face with Sweat') },
|
||||
{ emoji: '🤗', title: t('forum', 'Smiling Face with Open Hands') },
|
||||
{ emoji: '🤔', title: t('forum', 'Thinking Face') },
|
||||
{ emoji: '🤭', title: t('forum', 'Face with Hand Over Mouth') },
|
||||
{ emoji: '🤫', title: t('forum', 'Shushing Face') },
|
||||
{ emoji: '🤥', title: t('forum', 'Lying Face') },
|
||||
{ emoji: '😶', title: t('forum', 'Face Without Mouth') },
|
||||
{ emoji: '😐', title: t('forum', 'Neutral Face') },
|
||||
{ emoji: '😑', title: t('forum', 'Expressionless Face') },
|
||||
{ emoji: '😬', title: t('forum', 'Grimacing Face') },
|
||||
{ emoji: '🙄', title: t('forum', 'Face with Rolling Eyes') },
|
||||
{ emoji: '😯', title: t('forum', 'Hushed Face') },
|
||||
{ emoji: '😦', title: t('forum', 'Frowning Face with Open Mouth') },
|
||||
{ emoji: '😧', title: t('forum', 'Anguished Face') },
|
||||
{ emoji: '😮', title: t('forum', 'Face with Open Mouth') },
|
||||
{ emoji: '😲', title: t('forum', 'Astonished Face') },
|
||||
{ emoji: '🥱', title: t('forum', 'Yawning Face') },
|
||||
{ emoji: '😴', title: t('forum', 'Sleeping Face') },
|
||||
{ emoji: '🤤', title: t('forum', 'Drooling Face') },
|
||||
{ emoji: '😪', title: t('forum', 'Sleepy Face') },
|
||||
{ emoji: '😵', title: t('forum', 'Face with Crossed-Out Eyes') },
|
||||
{ emoji: '🤐', title: t('forum', 'Zipper-Mouth Face') },
|
||||
{ emoji: '🥴', title: t('forum', 'Woozy Face') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('forum', 'Gestures & Hands'),
|
||||
emojis: [
|
||||
{ emoji: '👋', title: t('forum', 'Waving Hand') },
|
||||
{ emoji: '🤚', title: t('forum', 'Raised Back of Hand') },
|
||||
{ emoji: '🖐', title: t('forum', 'Hand with Fingers Splayed') },
|
||||
{ emoji: '✋', title: t('forum', 'Raised Hand') },
|
||||
{ emoji: '🖖', title: t('forum', 'Vulcan Salute') },
|
||||
{ emoji: '👌', title: t('forum', 'OK Hand') },
|
||||
{ emoji: '🤌', title: t('forum', 'Pinched Fingers') },
|
||||
{ emoji: '🤏', title: t('forum', 'Pinching Hand') },
|
||||
{ emoji: '✌️', title: t('forum', 'Victory Hand') },
|
||||
{ emoji: '🤞', title: t('forum', 'Crossed Fingers') },
|
||||
{ emoji: '🤟', title: t('forum', 'Love-You Gesture') },
|
||||
{ emoji: '🤘', title: t('forum', 'Sign of the Horns') },
|
||||
{ emoji: '🤙', title: t('forum', 'Call Me Hand') },
|
||||
{ emoji: '👈', title: t('forum', 'Backhand Index Pointing Left') },
|
||||
{ emoji: '👉', title: t('forum', 'Backhand Index Pointing Right') },
|
||||
{ emoji: '👆', title: t('forum', 'Backhand Index Pointing Up') },
|
||||
{ emoji: '🖕', title: t('forum', 'Middle Finger') },
|
||||
{ emoji: '👇', title: t('forum', 'Backhand Index Pointing Down') },
|
||||
{ emoji: '☝️', title: t('forum', 'Index Pointing Up') },
|
||||
{ emoji: '👍', title: t('forum', 'Thumbs Up') },
|
||||
{ emoji: '👎', title: t('forum', 'Thumbs Down') },
|
||||
{ emoji: '✊', title: t('forum', 'Raised Fist') },
|
||||
{ emoji: '👊', title: t('forum', 'Oncoming Fist') },
|
||||
{ emoji: '🤛', title: t('forum', 'Left-Facing Fist') },
|
||||
{ emoji: '🤜', title: t('forum', 'Right-Facing Fist') },
|
||||
{ emoji: '👏', title: t('forum', 'Clapping Hands') },
|
||||
{ emoji: '🙌', title: t('forum', 'Raising Hands') },
|
||||
{ emoji: '👐', title: t('forum', 'Open Hands') },
|
||||
{ emoji: '🤲', title: t('forum', 'Palms Up Together') },
|
||||
{ emoji: '🤝', title: t('forum', 'Handshake') },
|
||||
{ emoji: '🙏', title: t('forum', 'Folded Hands') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('forum', 'Hearts & Love'),
|
||||
emojis: [
|
||||
{ emoji: '❤️', title: t('forum', 'Red Heart') },
|
||||
{ emoji: '💛', title: t('forum', 'Yellow Heart') },
|
||||
{ emoji: '💙', title: t('forum', 'Blue Heart') },
|
||||
{ emoji: '💜', title: t('forum', 'Purple Heart') },
|
||||
{ emoji: '🧡', title: t('forum', 'Orange Heart') },
|
||||
{ emoji: '💚', title: t('forum', 'Green Heart') },
|
||||
{ emoji: '🖤', title: t('forum', 'Black Heart') },
|
||||
{ emoji: '🤍', title: t('forum', 'White Heart') },
|
||||
{ emoji: '🤎', title: t('forum', 'Brown Heart') },
|
||||
{ emoji: '💔', title: t('forum', 'Broken Heart') },
|
||||
{ emoji: '❣️', title: t('forum', 'Heart Exclamation') },
|
||||
{ emoji: '💕', title: t('forum', 'Two Hearts') },
|
||||
{ emoji: '💞', title: t('forum', 'Revolving Hearts') },
|
||||
{ emoji: '💓', title: t('forum', 'Beating Heart') },
|
||||
{ emoji: '💗', title: t('forum', 'Growing Heart') },
|
||||
{ emoji: '💖', title: t('forum', 'Sparkling Heart') },
|
||||
{ emoji: '💘', title: t('forum', 'Heart with Arrow') },
|
||||
{ emoji: '💝', title: t('forum', 'Heart with Ribbon') },
|
||||
{ emoji: '💟', title: t('forum', 'Heart Decoration') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('forum', 'Symbols'),
|
||||
emojis: [
|
||||
{ emoji: '🎉', title: t('forum', 'Party Popper') },
|
||||
{ emoji: '🎊', title: t('forum', 'Confetti Ball') },
|
||||
{ emoji: '🎈', title: t('forum', 'Balloon') },
|
||||
{ emoji: '🎁', title: t('forum', 'Wrapped Gift') },
|
||||
{ emoji: '🏆', title: t('forum', 'Trophy') },
|
||||
{ emoji: '🥇', title: t('forum', '1st Place Medal') },
|
||||
{ emoji: '🥈', title: t('forum', '2nd Place Medal') },
|
||||
{ emoji: '🥉', title: t('forum', '3rd Place Medal') },
|
||||
{ emoji: '⭐', title: t('forum', 'Star') },
|
||||
{ emoji: '🌟', title: t('forum', 'Glowing Star') },
|
||||
{ emoji: '✨', title: t('forum', 'Sparkles') },
|
||||
{ emoji: '💫', title: t('forum', 'Dizzy') },
|
||||
{ emoji: '🔥', title: t('forum', 'Fire') },
|
||||
{ emoji: '💯', title: t('forum', 'Hundred Points') },
|
||||
{ emoji: '✅', title: t('forum', 'Check Mark Button') },
|
||||
{ emoji: '❌', title: t('forum', 'Cross Mark') },
|
||||
{ emoji: '⚠️', title: t('forum', 'Warning') },
|
||||
{ emoji: '❗', title: t('forum', 'Exclamation Mark') },
|
||||
{ emoji: '❓', title: t('forum', 'Question Mark') },
|
||||
{ emoji: '💬', title: t('forum', 'Speech Balloon') },
|
||||
{ emoji: '💭', title: t('forum', 'Thought Balloon') },
|
||||
{ emoji: '👀', title: t('forum', 'Eyes') },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -12,6 +12,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: '/thread/:id', component: () => import('@/views/ThreadView.vue') },
|
||||
{ path: '/t/:slug', component: () => import('@/views/ThreadView.vue') },
|
||||
{ path: '/u/:userId', component: () => import('@/views/ProfileView.vue') },
|
||||
{ path: '/preferences', component: () => import('@/views/UserPreferencesView.vue') },
|
||||
{ path: '/search', component: () => import('@/views/SearchView.vue') },
|
||||
{ path: '/admin', component: () => import('@/views/admin/AdminDashboard.vue') },
|
||||
{ path: '/admin/settings', component: () => import('@/views/admin/AdminGeneralSettings.vue') },
|
||||
|
||||
@@ -1,431 +0,0 @@
|
||||
<template>
|
||||
<div class="user-inner">
|
||||
<!-- Toolbar -->
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<div style="max-width: 320px">
|
||||
<NcTextField
|
||||
v-model="search"
|
||||
:label="strings.searchLabel"
|
||||
:placeholder="strings.searchPlaceholder"
|
||||
trailing-button-icon="close"
|
||||
:show-trailing-button="search !== ''"
|
||||
@trailing-button-click="clearSearch"
|
||||
/>
|
||||
</div>
|
||||
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<NcButton type="secondary" @click="toggleForm">
|
||||
{{ formOpen ? strings.hideForm : strings.showForm }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
|
||||
<!-- Quick info / doc -->
|
||||
<NcNoteCard class="mt-12" type="info">
|
||||
<p v-html="strings.quickHelp"></p>
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Add item form -->
|
||||
<section v-if="formOpen" class="card mt-16">
|
||||
<h3 class="card-title">{{ strings.formHeader }}</h3>
|
||||
<div class="row gap-16 align-start">
|
||||
<div style="max-width: 260px">
|
||||
<NcTextField
|
||||
v-model="name"
|
||||
:label="strings.nameInputLabel"
|
||||
:placeholder="strings.nameInputPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 220px">
|
||||
<NcSelect
|
||||
v-model="themeLabel"
|
||||
:options="themeOptionsLabels"
|
||||
:input-label="strings.themeLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row gap-8 align-center">
|
||||
<NcButton @click="addFromForm" :disabled="name.trim() === '' || loading">
|
||||
{{ strings.add }}
|
||||
</NcButton>
|
||||
<NcButton type="tertiary" @click="clearForm" :disabled="loading">
|
||||
{{ strings.clear }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-12">
|
||||
{{ strings.livePreview }} <b>{{ previewGreeting }}</b>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 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="filteredHellos.length === 0"
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="seedOne">{{ strings.addExample }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- List -->
|
||||
<section v-else class="mt-16">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50%">{{ strings.colMessage }}</th>
|
||||
<th style="width: 30%">{{ strings.colAt }}</th>
|
||||
<th style="width: 20%">{{ strings.colActions }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(hello, idx) in filteredHellos" :key="hello.id">
|
||||
<td class="ellipsis">
|
||||
<span class="mono">{{ hello.message }}</span>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
<NcDateTime v-if="hello.at" :timestamp="new Date(hello.at).valueOf()" />
|
||||
<span v-else class="muted">{{ strings.never }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row gap-8">
|
||||
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
|
||||
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div class="row gap-12 mt-12">
|
||||
<NcButton type="secondary" @click="refresh" :disabled="loading">{{
|
||||
strings.refresh
|
||||
}}</NcButton>
|
||||
<NcButton type="secondary" @click="clearAll" :disabled="loading || hellos.length === 0">
|
||||
{{ strings.clearAll }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Inner view rendered inside AppUserWrapper via <router-view>.
|
||||
* Uses the Hello controller (GET/POST /hello).
|
||||
*/
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
|
||||
import { ocs } from '@/axios'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
|
||||
export default {
|
||||
name: 'AppUserHome',
|
||||
components: {
|
||||
NcButton,
|
||||
NcNoteCard,
|
||||
NcTextField,
|
||||
NcSelect,
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
NcDateTime,
|
||||
AppToolbar,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
formOpen: true,
|
||||
|
||||
// Toolbar
|
||||
search: '',
|
||||
|
||||
// Form data
|
||||
name: '',
|
||||
themeLabel: null,
|
||||
themeOptions: [
|
||||
{ label: t('forum', 'Light'), value: 'light' },
|
||||
{ label: t('forum', 'Dark'), value: 'dark' },
|
||||
{
|
||||
label: n('forum', 'System (1 option)', 'System (%n options)', 2),
|
||||
value: 'system',
|
||||
},
|
||||
],
|
||||
|
||||
// List of "hellos"
|
||||
hellos: [],
|
||||
|
||||
strings: {
|
||||
// Toolbar
|
||||
searchLabel: t('forum', 'Search'),
|
||||
searchPlaceholder: t('forum', 'Filter messages…'),
|
||||
refresh: t('forum', 'Refresh'),
|
||||
showForm: t('forum', 'Show form'),
|
||||
hideForm: t('forum', 'Hide form'),
|
||||
|
||||
// Info
|
||||
quickHelp: t(
|
||||
'forum',
|
||||
'Use the form to post a hello. The list shows recent hellos fetched from the server. All user-visible text is centralized in {cStart}strings{cEnd}.',
|
||||
{ cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
|
||||
// Form
|
||||
formHeader: t('forum', 'Say hello'),
|
||||
nameInputLabel: t('forum', 'Name'),
|
||||
nameInputPlaceholder: t('forum', 'e.g. Ada'),
|
||||
themeLabel: t('forum', 'Theme'),
|
||||
add: t('forum', 'Add'),
|
||||
clear: t('forum', 'Clear'),
|
||||
livePreview: t('forum', 'Preview:'),
|
||||
|
||||
// List
|
||||
loading: t('forum', 'Loading…'),
|
||||
emptyTitle: t('forum', 'No hellos yet'),
|
||||
emptyDesc: t('forum', 'Try adding one using the form above.'),
|
||||
addExample: t('forum', 'Add example'),
|
||||
colMessage: t('forum', 'Message'),
|
||||
colAt: t('forum', 'Time'),
|
||||
colActions: t('forum', 'Actions'),
|
||||
duplicate: t('forum', 'Duplicate'),
|
||||
remove: t('forum', 'Remove'),
|
||||
clearAll: t('forum', 'Clear all'),
|
||||
never: t('forum', 'Never'),
|
||||
},
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.refresh()
|
||||
},
|
||||
computed: {
|
||||
themeOptionsLabels() {
|
||||
return this.themeOptions.map((x) => x.label)
|
||||
},
|
||||
activeTheme() {
|
||||
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
|
||||
},
|
||||
previewGreeting() {
|
||||
const n = this.name.trim()
|
||||
return n ? `Hello, ${n}!` : 'Hello!'
|
||||
},
|
||||
filteredHellos() {
|
||||
const q = this.search.trim().toLowerCase()
|
||||
if (!q) return this.hellos
|
||||
return this.hellos.filter((h) => h.message.toLowerCase().includes(q))
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleForm() {
|
||||
this.formOpen = !this.formOpen
|
||||
},
|
||||
clearForm() {
|
||||
this.name = ''
|
||||
this.themeLabel = null
|
||||
},
|
||||
clearSearch() {
|
||||
this.search = ''
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
this.loading = true
|
||||
// GET /hello -> { ocs: { data: { message, at } } }
|
||||
const resp = await ocs.get('/hello')
|
||||
const data = resp.data
|
||||
if (data?.message) {
|
||||
this.hellos.unshift({
|
||||
id: genId(),
|
||||
message: data.message,
|
||||
at: data.at ?? null,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async addFromForm() {
|
||||
const name = this.name.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
this.loading = true
|
||||
const payload = {
|
||||
name,
|
||||
theme: this.activeTheme.value,
|
||||
items: [],
|
||||
counter: 0,
|
||||
}
|
||||
// POST /hello -> { ocs: { data: { message, at } } }
|
||||
const resp = await ocs.post('/hello', { data: payload })
|
||||
const data = resp.data
|
||||
const message = data?.message ?? `Hello, ${name}!`
|
||||
const at = data?.at ?? new Date().toISOString()
|
||||
this.hellos.unshift({ id: genId(), message, at })
|
||||
this.clearForm()
|
||||
this.formOpen = false
|
||||
} catch (e) {
|
||||
console.error('Failed to add hello', e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
duplicate(index) {
|
||||
const src = this.hellos[index]
|
||||
if (!src) return
|
||||
this.hellos.splice(index + 1, 0, { ...src, id: genId() })
|
||||
},
|
||||
|
||||
remove(index) {
|
||||
this.hellos.splice(index, 1)
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
this.hellos = []
|
||||
},
|
||||
|
||||
seedOne() {
|
||||
this.hellos.push({
|
||||
id: genId(),
|
||||
message: '👋 Hello example',
|
||||
at: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function genId() {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-inner {
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
|
||||
&.align-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.gap-8 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&.gap-12 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&.gap-16 {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: var(--color-main-background);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
thead tr,
|
||||
tr:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody tr {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,64 +1,60 @@
|
||||
<template>
|
||||
<div class="categories-view">
|
||||
<header class="page-header">
|
||||
<h2>{{ forumTitle }}</h2>
|
||||
<p class="muted">{{ forumSubtitle }}</p>
|
||||
</header>
|
||||
<PageWrapper :full-width="true">
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #right>
|
||||
<NcButton
|
||||
@click="refresh"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.refresh"
|
||||
:title="strings.refresh"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<h2 class="view-title">{{ strings.title }}</h2>
|
||||
</template>
|
||||
<div class="categories-view">
|
||||
<PageHeader :title="forumTitle" :subtitle="forumSubtitle" :loading="settingsLoading" />
|
||||
|
||||
<template #right>
|
||||
<NcButton
|
||||
@click="refresh"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.refresh"
|
||||
:title="strings.refresh"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Loading state -->
|
||||
<div class="center mt-16" v-if="loading">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -67,6 +63,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import CategoryCard from '@/components/CategoryCard.vue'
|
||||
import RefreshIcon from '@icons/Refresh.vue'
|
||||
import { useCategories } from '@/composables/useCategories'
|
||||
@@ -81,6 +79,8 @@ export default defineComponent({
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
AppToolbar,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
CategoryCard,
|
||||
RefreshIcon,
|
||||
},
|
||||
@@ -95,10 +95,10 @@ export default defineComponent({
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
settingsLoading: true,
|
||||
forumTitle: t('forum', 'Forum'),
|
||||
forumSubtitle: t('forum', 'Welcome to the forum'),
|
||||
strings: {
|
||||
title: t('forum', 'Categories'),
|
||||
refresh: t('forum', 'Refresh'),
|
||||
loading: t('forum', 'Loading…'),
|
||||
emptyTitle: t('forum', 'No categories yet'),
|
||||
@@ -118,12 +118,15 @@ export default defineComponent({
|
||||
methods: {
|
||||
async fetchForumSettings() {
|
||||
try {
|
||||
const response = await ocs.get<{ title: string; subtitle: string }>('/admin/settings')
|
||||
this.settingsLoading = true
|
||||
const response = await ocs.get<{ title: string; subtitle: string }>('/settings')
|
||||
this.forumTitle = response.data.title || t('forum', 'Forum')
|
||||
this.forumSubtitle = response.data.subtitle || t('forum', 'Welcome to the forum')
|
||||
this.forumSubtitle = response.data.subtitle || t('forum', 'Welcome to the forum!')
|
||||
} catch (e) {
|
||||
// Silently fail and use defaults if settings can't be loaded
|
||||
console.debug('Could not load forum settings, using defaults', e)
|
||||
} finally {
|
||||
this.settingsLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,100 +1,105 @@
|
||||
<template>
|
||||
<div class="category-view">
|
||||
<!-- Toolbar -->
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
<PageWrapper :full-width="true">
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<NcButton
|
||||
@click="refresh"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.refresh"
|
||||
:title="strings.refresh"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton @click="createThread" :disabled="loading" variant="primary">
|
||||
<template #icon>
|
||||
<MessagePlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.newThread }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
<template #right>
|
||||
<NcButton
|
||||
@click="refresh"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.refresh"
|
||||
:title="strings.refresh"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton @click="createThread" :disabled="loading" variant="primary">
|
||||
<template #icon>
|
||||
<MessagePlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.newThread }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
<div class="category-view">
|
||||
<!-- Category Header -->
|
||||
<PageHeader
|
||||
v-if="category && !loading"
|
||||
:title="category.name"
|
||||
:subtitle="category.description || undefined"
|
||||
class="mt-16"
|
||||
/>
|
||||
|
||||
<!-- 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">
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
{{ 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 @click="createThread" variant="primary">
|
||||
<template #icon>
|
||||
<MessagePlusIcon :size="20" />
|
||||
</template>
|
||||
{{ 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"
|
||||
:is-unread="isThreadUnread(thread)"
|
||||
@click="navigateToThread(thread)"
|
||||
/>
|
||||
<!-- Loading state -->
|
||||
<div class="center mt-16" v-if="loading">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</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>
|
||||
<!-- Error state -->
|
||||
<NcEmptyContent
|
||||
v-else-if="error"
|
||||
:title="strings.errorTitle"
|
||||
:description="error"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="refresh">
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
{{ 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 @click="createThread" variant="primary">
|
||||
<template #icon>
|
||||
<MessagePlusIcon :size="20" />
|
||||
</template>
|
||||
{{ 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"
|
||||
:is-unread="isThreadUnread(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>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -103,6 +108,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import ThreadCard from '@/components/ThreadCard.vue'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import RefreshIcon from '@icons/Refresh.vue'
|
||||
@@ -118,6 +125,8 @@ export default defineComponent({
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
AppToolbar,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
ThreadCard,
|
||||
ArrowLeftIcon,
|
||||
RefreshIcon,
|
||||
@@ -299,27 +308,6 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
|
||||
.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;
|
||||
|
||||
@@ -1,51 +1,55 @@
|
||||
<template>
|
||||
<!-- Toolbar -->
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
<PageWrapper>
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
|
||||
<div class="create-thread-view">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header mt-16">
|
||||
<h2 class="page-title">{{ strings.title }}</h2>
|
||||
<p v-if="category" class="page-subtitle">{{ strings.subtitle(category.name) }}</p>
|
||||
<div class="create-thread-view">
|
||||
<!-- Page Header -->
|
||||
<PageHeader
|
||||
:title="strings.title"
|
||||
:subtitle="category ? strings.subtitle(category.name) : ''"
|
||||
class="mt-16"
|
||||
/>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div class="center mt-16" v-if="loading && !category">
|
||||
<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="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Create Thread Form -->
|
||||
<div v-else class="mt-16">
|
||||
<ThreadCreateForm ref="createForm" @submit="handleCreateThread" @cancel="goBack" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div class="center mt-16" v-if="loading && !category">
|
||||
<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="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Create Thread Form -->
|
||||
<div v-else class="mt-16">
|
||||
<ThreadCreateForm ref="createForm" @submit="handleCreateThread" @cancel="goBack" />
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -54,6 +58,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import ThreadCreateForm from '@/components/ThreadCreateForm.vue'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import type { Category, Thread } from '@/types'
|
||||
@@ -68,6 +74,8 @@ export default defineComponent({
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
AppToolbar,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
ThreadCreateForm,
|
||||
ArrowLeftIcon,
|
||||
},
|
||||
@@ -172,9 +180,6 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.create-thread-view {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -1,154 +1,157 @@
|
||||
<template>
|
||||
<div class="profile-view">
|
||||
<!-- Toolbar -->
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
<PageWrapper>
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<NcButton
|
||||
@click="refresh"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.refresh"
|
||||
:title="strings.refresh"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
<template #right>
|
||||
<NcButton
|
||||
@click="refresh"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.refresh"
|
||||
:title="strings.refresh"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<!-- 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">
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.retry }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Profile content -->
|
||||
<div v-else class="profile-content mt-16">
|
||||
<!-- User Header -->
|
||||
<div class="user-header">
|
||||
<div class="user-avatar">
|
||||
<NcAvatar :user="userId" :size="80" :show-user-status="false" />
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<h2 class="user-name">{{ displayName }}</h2>
|
||||
<div class="user-meta">
|
||||
<span v-if="userStats && userStats.createdAt" class="meta-item">
|
||||
<span class="meta-label">{{ strings.firstPost }}</span>
|
||||
<NcDateTime :timestamp="userStats.createdAt * 1000" />
|
||||
</span>
|
||||
<span v-if="userStats && userStats.createdAt" class="meta-divider">·</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">{{ strings.threads }}</span>
|
||||
<span class="meta-value">{{ userStats?.threadCount || 0 }}</span>
|
||||
</span>
|
||||
<span class="meta-divider">·</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">{{ strings.posts }}</span>
|
||||
<span class="meta-value">{{ userStats?.postCount || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-view">
|
||||
<!-- Loading state -->
|
||||
<div class="center mt-16" v-if="loading">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="profile-tabs mt-24">
|
||||
<div class="tabs-header">
|
||||
<button
|
||||
class="tab-button"
|
||||
:class="{ active: activeTab === 'threads' }"
|
||||
@click="activeTab = 'threads'"
|
||||
>
|
||||
{{ strings.threads }} ({{ threads.length }})
|
||||
</button>
|
||||
<button
|
||||
class="tab-button"
|
||||
:class="{ active: activeTab === 'posts' }"
|
||||
@click="activeTab = 'posts'"
|
||||
>
|
||||
{{ strings.replies }} ({{ posts.length }})
|
||||
</button>
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<NcEmptyContent
|
||||
v-else-if="error"
|
||||
:title="strings.errorTitle"
|
||||
:description="error"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="refresh">
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.retry }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<div class="tabs-content mt-16">
|
||||
<!-- Threads Tab -->
|
||||
<div v-if="activeTab === 'threads'" class="tab-pane">
|
||||
<div v-if="loadingThreads" class="center">
|
||||
<NcLoadingIcon :size="24" />
|
||||
</div>
|
||||
<NcEmptyContent
|
||||
v-else-if="threads.length === 0"
|
||||
:title="strings.noThreads"
|
||||
:description="strings.noThreadsDesc"
|
||||
/>
|
||||
<div v-else class="threads-list">
|
||||
<ThreadCard
|
||||
v-for="thread in threads"
|
||||
:key="thread.id"
|
||||
:thread="thread"
|
||||
@click="navigateToThread(thread)"
|
||||
/>
|
||||
<!-- Profile content -->
|
||||
<div v-else class="profile-content mt-16">
|
||||
<!-- User Header -->
|
||||
<div class="user-header">
|
||||
<div class="user-avatar">
|
||||
<NcAvatar :user="userId" :size="80" :show-user-status="false" />
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<h2 class="user-name">{{ displayName }}</h2>
|
||||
<div class="user-meta">
|
||||
<span v-if="userStats && userStats.createdAt" class="meta-item">
|
||||
<span class="meta-label">{{ strings.firstPost }}</span>
|
||||
<NcDateTime :timestamp="userStats.createdAt * 1000" />
|
||||
</span>
|
||||
<span v-if="userStats && userStats.createdAt" class="meta-divider">·</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">{{ strings.threads }}</span>
|
||||
<span class="meta-value">{{ userStats?.threadCount || 0 }}</span>
|
||||
</span>
|
||||
<span class="meta-divider">·</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">{{ strings.posts }}</span>
|
||||
<span class="meta-value">{{ userStats?.postCount || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Tab -->
|
||||
<div v-if="activeTab === 'posts'" class="tab-pane">
|
||||
<div v-if="loadingPosts" class="center">
|
||||
<NcLoadingIcon :size="24" />
|
||||
<!-- Tabs -->
|
||||
<div class="profile-tabs mt-24">
|
||||
<div class="tabs-header">
|
||||
<button
|
||||
class="tab-button"
|
||||
:class="{ active: activeTab === 'threads' }"
|
||||
@click="activeTab = 'threads'"
|
||||
>
|
||||
{{ strings.threads }} ({{ threads.length }})
|
||||
</button>
|
||||
<button
|
||||
class="tab-button"
|
||||
:class="{ active: activeTab === 'posts' }"
|
||||
@click="activeTab = 'posts'"
|
||||
>
|
||||
{{ strings.replies }} ({{ posts.length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs-content mt-16">
|
||||
<!-- Threads Tab -->
|
||||
<div v-if="activeTab === 'threads'" class="tab-pane">
|
||||
<div v-if="loadingThreads" class="center">
|
||||
<NcLoadingIcon :size="24" />
|
||||
</div>
|
||||
<NcEmptyContent
|
||||
v-else-if="threads.length === 0"
|
||||
:title="strings.noThreads"
|
||||
:description="strings.noThreadsDesc"
|
||||
/>
|
||||
<div v-else class="threads-list">
|
||||
<ThreadCard
|
||||
v-for="thread in threads"
|
||||
:key="thread.id"
|
||||
:thread="thread"
|
||||
@click="navigateToThread(thread)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NcEmptyContent
|
||||
v-else-if="posts.length === 0"
|
||||
:title="strings.noPosts"
|
||||
:description="strings.noPostsDesc"
|
||||
/>
|
||||
<div v-else class="posts-list">
|
||||
<div
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="post-item"
|
||||
@click="navigateToPost(post)"
|
||||
>
|
||||
<div class="post-meta">
|
||||
<span class="post-thread" v-if="post.threadTitle">
|
||||
{{ strings.inThread }} <strong>{{ post.threadTitle }}</strong>
|
||||
</span>
|
||||
<span class="post-date">
|
||||
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
|
||||
</span>
|
||||
|
||||
<!-- Posts Tab -->
|
||||
<div v-if="activeTab === 'posts'" class="tab-pane">
|
||||
<div v-if="loadingPosts" class="center">
|
||||
<NcLoadingIcon :size="24" />
|
||||
</div>
|
||||
<NcEmptyContent
|
||||
v-else-if="posts.length === 0"
|
||||
:title="strings.noPosts"
|
||||
:description="strings.noPostsDesc"
|
||||
/>
|
||||
<div v-else class="posts-list">
|
||||
<div
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="post-item"
|
||||
@click="navigateToPost(post)"
|
||||
>
|
||||
<div class="post-meta">
|
||||
<span class="post-thread" v-if="post.threadTitle">
|
||||
{{ strings.inThread }} <strong>{{ post.threadTitle }}</strong>
|
||||
</span>
|
||||
<span class="post-date">
|
||||
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="post-content" v-html="post.content"></div>
|
||||
</div>
|
||||
<div class="post-content" v-html="post.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -159,6 +162,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import ThreadCard from '@/components/ThreadCard.vue'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import RefreshIcon from '@icons/Refresh.vue'
|
||||
@@ -177,6 +181,7 @@ export default defineComponent({
|
||||
NcAvatar,
|
||||
NcDateTime,
|
||||
AppToolbar,
|
||||
PageWrapper,
|
||||
ThreadCard,
|
||||
ArrowLeftIcon,
|
||||
RefreshIcon,
|
||||
@@ -358,158 +363,225 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-view {
|
||||
padding: 16px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mt-24 {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
.tabs-header {
|
||||
.center {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mt-24 {
|
||||
margin-top: 24px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
transition: all 0.2s;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-light);
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
.user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
|
||||
&.active {
|
||||
color: var(--color-text-light);
|
||||
border-bottom-color: var(--color-text-light);
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
min-height: 200px;
|
||||
.user-avatar {
|
||||
@media (max-width: 768px) {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.threads-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.user-info {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
|
||||
.posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
padding: 16px;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-primary-element);
|
||||
@media (max-width: 768px) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
.user-name {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
|
||||
.post-thread {
|
||||
strong {
|
||||
@media (max-width: 768px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.post-content {
|
||||
color: var(--color-text-light);
|
||||
line-height: 1.6;
|
||||
.meta-divider {
|
||||
color: var(--color-text-maxcontrast);
|
||||
|
||||
// Truncate long content
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
@media (max-width: 480px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-maxcontrast);
|
||||
transition: all 0.2s;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-light);
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--color-text-light);
|
||||
border-bottom-color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.threads-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
padding: 16px;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-thread {
|
||||
strong {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.post-content {
|
||||
color: var(--color-text-light);
|
||||
line-height: 1.6;
|
||||
|
||||
// Truncate long content
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,132 +1,134 @@
|
||||
<template>
|
||||
<div class="search-view">
|
||||
<!-- Search Header -->
|
||||
<div class="search-header">
|
||||
<h2 class="search-title">{{ strings.searchTitle }}</h2>
|
||||
<PageWrapper>
|
||||
<div class="search-view">
|
||||
<!-- Search Header -->
|
||||
<div class="search-header">
|
||||
<h2 class="search-title">{{ strings.searchTitle }}</h2>
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="strings.searchPlaceholder"
|
||||
class="search-input"
|
||||
@keydown.enter="performSearch"
|
||||
/>
|
||||
<NcButton variant="primary" @click="performSearch" :disabled="!canSearch || loading">
|
||||
<template #icon>
|
||||
<MagnifyIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.search }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Search Options -->
|
||||
<div class="search-options">
|
||||
<NcCheckboxRadioSwitch v-model="searchThreads" @update:checked="onOptionsChange">
|
||||
{{ strings.searchThreads }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch v-model="searchPosts" @update:checked="onOptionsChange">
|
||||
{{ strings.searchPosts }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcButton variant="tertiary" @click="showSyntaxHelp = !showSyntaxHelp">
|
||||
<template #icon>
|
||||
<HelpCircleIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.syntaxHelp }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Syntax Help -->
|
||||
<div v-if="showSyntaxHelp" class="syntax-help">
|
||||
<h3>{{ strings.searchSyntax }}</h3>
|
||||
<ul>
|
||||
<li><code>"exact phrase"</code> - {{ strings.helpExactPhrase }}</li>
|
||||
<li><code>term1 AND term2</code> - {{ strings.helpAnd }}</li>
|
||||
<li><code>term1 OR term2</code> - {{ strings.helpOr }}</li>
|
||||
<li><code>(term1 OR term2) AND term3</code> - {{ strings.helpGrouping }}</li>
|
||||
<li><code>-excluded</code> - {{ strings.helpExclude }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.searching }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<NcEmptyContent
|
||||
v-else-if="error"
|
||||
:title="strings.errorTitle"
|
||||
:description="error"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="performSearch">{{ strings.retry }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Empty State (no query) -->
|
||||
<NcEmptyContent
|
||||
v-else-if="!hasSearched"
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #icon>
|
||||
<MagnifyIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- No Results -->
|
||||
<NcEmptyContent
|
||||
v-else-if="hasSearched && threadResults.length === 0 && postResults.length === 0"
|
||||
:title="strings.noResultsTitle"
|
||||
:description="strings.noResultsDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #icon>
|
||||
<MagnifyIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-else class="search-results mt-16">
|
||||
<!-- Thread Results Section -->
|
||||
<section v-if="searchThreads && threadResults.length > 0" class="results-section">
|
||||
<h3 class="results-header">
|
||||
{{ strings.threadResults(threadCount) }}
|
||||
</h3>
|
||||
<div class="results-list">
|
||||
<SearchThreadResult
|
||||
v-for="thread in threadResults"
|
||||
:key="thread.id"
|
||||
:thread="thread"
|
||||
:query="currentQuery"
|
||||
@click="navigateToThread(thread)"
|
||||
<!-- Search Input -->
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="strings.searchPlaceholder"
|
||||
class="search-input"
|
||||
@keydown.enter="performSearch"
|
||||
/>
|
||||
<NcButton variant="primary" @click="performSearch" :disabled="!canSearch || loading">
|
||||
<template #icon>
|
||||
<MagnifyIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.search }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Post Results Section -->
|
||||
<section v-if="searchPosts && postResults.length > 0" class="results-section mt-16">
|
||||
<h3 class="results-header">
|
||||
{{ strings.postResults(postCount) }}
|
||||
</h3>
|
||||
<div class="results-list">
|
||||
<SearchPostResult
|
||||
v-for="post in postResults"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:query="currentQuery"
|
||||
/>
|
||||
<!-- Search Options -->
|
||||
<div class="search-options">
|
||||
<NcCheckboxRadioSwitch v-model="searchThreads" @update:checked="onOptionsChange">
|
||||
{{ strings.searchThreads }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch v-model="searchPosts" @update:checked="onOptionsChange">
|
||||
{{ strings.searchPosts }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcButton variant="tertiary" @click="showSyntaxHelp = !showSyntaxHelp">
|
||||
<template #icon>
|
||||
<HelpCircleIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.syntaxHelp }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Syntax Help -->
|
||||
<div v-if="showSyntaxHelp" class="syntax-help">
|
||||
<h3>{{ strings.searchSyntax }}</h3>
|
||||
<ul>
|
||||
<li><code>"exact phrase"</code> - {{ strings.helpExactPhrase }}</li>
|
||||
<li><code>term1 AND term2</code> - {{ strings.helpAnd }}</li>
|
||||
<li><code>term1 OR term2</code> - {{ strings.helpOr }}</li>
|
||||
<li><code>(term1 OR term2) AND term3</code> - {{ strings.helpGrouping }}</li>
|
||||
<li><code>-excluded</code> - {{ strings.helpExclude }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.searching }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<NcEmptyContent
|
||||
v-else-if="error"
|
||||
:title="strings.errorTitle"
|
||||
:description="error"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="performSearch">{{ strings.retry }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Empty State (no query) -->
|
||||
<NcEmptyContent
|
||||
v-else-if="!hasSearched"
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #icon>
|
||||
<MagnifyIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- No Results -->
|
||||
<NcEmptyContent
|
||||
v-else-if="hasSearched && threadResults.length === 0 && postResults.length === 0"
|
||||
:title="strings.noResultsTitle"
|
||||
:description="strings.noResultsDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #icon>
|
||||
<MagnifyIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-else class="search-results mt-16">
|
||||
<!-- Thread Results Section -->
|
||||
<section v-if="searchThreads && threadResults.length > 0" class="results-section">
|
||||
<h3 class="results-header">
|
||||
{{ strings.threadResults(threadCount) }}
|
||||
</h3>
|
||||
<div class="results-list">
|
||||
<SearchThreadResult
|
||||
v-for="thread in threadResults"
|
||||
:key="thread.id"
|
||||
:thread="thread"
|
||||
:query="currentQuery"
|
||||
@click="navigateToThread(thread)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Post Results Section -->
|
||||
<section v-if="searchPosts && postResults.length > 0" class="results-section mt-16">
|
||||
<h3 class="results-header">
|
||||
{{ strings.postResults(postCount) }}
|
||||
</h3>
|
||||
<div class="results-list">
|
||||
<SearchPostResult
|
||||
v-for="post in postResults"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:query="currentQuery"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -135,6 +137,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import MagnifyIcon from '@icons/Magnify.vue'
|
||||
import HelpCircleIcon from '@icons/HelpCircle.vue'
|
||||
import SearchThreadResult from '@/components/SearchThreadResult.vue'
|
||||
@@ -151,6 +154,7 @@ export default defineComponent({
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
NcCheckboxRadioSwitch,
|
||||
PageWrapper,
|
||||
MagnifyIcon,
|
||||
HelpCircleIcon,
|
||||
SearchThreadResult,
|
||||
@@ -276,10 +280,6 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-view {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
|
||||
.search-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -1,195 +1,198 @@
|
||||
<template>
|
||||
<div class="thread-view">
|
||||
<!-- Toolbar -->
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ thread?.categoryName ? strings.backToCategory(thread.categoryName) : strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<!-- Subscription toggle switch -->
|
||||
<NcCheckboxRadioSwitch
|
||||
v-if="!loading && thread"
|
||||
v-model="thread.isSubscribed"
|
||||
@update:model-value="handleToggleSubscription"
|
||||
type="switch"
|
||||
>
|
||||
<span class="icon-label">
|
||||
<BellIcon :size="20" />
|
||||
{{ thread.isSubscribed ? strings.subscribed : strings.subscribe }}
|
||||
</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcButton
|
||||
@click="refresh"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.refresh"
|
||||
:title="strings.refresh"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<!-- Moderation buttons (only visible to moderators) -->
|
||||
<template v-if="canModerate && !loading">
|
||||
<NcButton
|
||||
@click="handleToggleLock"
|
||||
:aria-label="thread?.isLocked ? strings.unlockThread : strings.lockThread"
|
||||
:title="thread?.isLocked ? strings.unlockThread : strings.lockThread"
|
||||
>
|
||||
<PageWrapper :full-width="true">
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<LockOpenIcon v-if="thread?.isLocked" :size="20" />
|
||||
<LockIcon v-else :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<NcButton
|
||||
@click="handleTogglePin"
|
||||
:aria-label="thread?.isPinned ? strings.unpinThread : strings.pinThread"
|
||||
:title="thread?.isPinned ? strings.unpinThread : strings.pinThread"
|
||||
>
|
||||
<template #icon>
|
||||
<PinOffIcon v-if="thread?.isPinned" :size="20" />
|
||||
<PinIcon v-else :size="20" />
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ thread?.categoryName ? strings.backToCategory(thread.categoryName) : strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<NcButton
|
||||
@click="replyToThread"
|
||||
:disabled="loading || (thread?.isLocked && !canModerate)"
|
||||
variant="primary"
|
||||
>
|
||||
<template #icon>
|
||||
<ReplyIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.reply }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
|
||||
<!-- 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">
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
{{ 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">
|
||||
<PinIcon :size="20" />
|
||||
</span>
|
||||
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
|
||||
<LockIcon :size="20" />
|
||||
</span>
|
||||
{{ thread.title }}
|
||||
</h2>
|
||||
<div class="thread-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">{{ strings.by }}</span>
|
||||
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
|
||||
{{ thread.authorDisplayName || thread.authorId }}
|
||||
<template #right>
|
||||
<!-- Subscription toggle switch -->
|
||||
<NcCheckboxRadioSwitch
|
||||
v-if="!loading && thread"
|
||||
v-model="thread.isSubscribed"
|
||||
@update:model-value="handleToggleSubscription"
|
||||
type="switch"
|
||||
>
|
||||
<span class="icon-label">
|
||||
<BellIcon :size="20" />
|
||||
{{ thread.isSubscribed ? strings.subscribed : strings.subscribe }}
|
||||
</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">
|
||||
<EyeIcon :size="16" />
|
||||
</span>
|
||||
<span class="stat-label">{{ strings.views(thread.viewCount) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<!-- 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"
|
||||
:ref="(el) => setPostCardRef(el, post.id)"
|
||||
:post="post"
|
||||
:is-first-post="index === 0"
|
||||
:is-unread="isPostUnread(post)"
|
||||
@reply="handleReply"
|
||||
@update="handleUpdate"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
<NcButton
|
||||
@click="refresh"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.refresh"
|
||||
:title="strings.refresh"
|
||||
>
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<!-- Pagination info -->
|
||||
<div v-if="posts.length >= limit" class="pagination-info mt-16">
|
||||
<p class="muted">{{ strings.showingPosts(posts.length) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Moderation buttons (only visible to moderators) -->
|
||||
<template v-if="canModerate && !loading">
|
||||
<NcButton
|
||||
@click="handleToggleLock"
|
||||
:aria-label="thread?.isLocked ? strings.unlockThread : strings.lockThread"
|
||||
:title="thread?.isLocked ? strings.unlockThread : strings.lockThread"
|
||||
>
|
||||
<template #icon>
|
||||
<LockOpenIcon v-if="thread?.isLocked" :size="20" />
|
||||
<LockIcon v-else :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<!-- 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 @click="replyToThread" variant="primary">
|
||||
<template #icon>
|
||||
<ReplyIcon :size="20" />
|
||||
<NcButton
|
||||
@click="handleTogglePin"
|
||||
:aria-label="thread?.isPinned ? strings.unpinThread : strings.pinThread"
|
||||
:title="thread?.isPinned ? strings.unpinThread : strings.pinThread"
|
||||
>
|
||||
<template #icon>
|
||||
<PinOffIcon v-if="thread?.isPinned" :size="20" />
|
||||
<PinIcon v-else :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</template>
|
||||
{{ strings.reply }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Locked message (only shown to non-moderators) -->
|
||||
<div
|
||||
v-if="!loading && !error && thread && thread.isLocked && !canModerate"
|
||||
class="locked-message mt-16"
|
||||
>
|
||||
<NcEmptyContent :title="strings.locked" :description="strings.lockedMessage">
|
||||
<template #icon>
|
||||
<LockIcon :size="64" />
|
||||
<NcButton
|
||||
@click="replyToThread"
|
||||
:disabled="loading || (thread?.isLocked && !canModerate)"
|
||||
variant="primary"
|
||||
>
|
||||
<template #icon>
|
||||
<ReplyIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.reply }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<div class="thread-view">
|
||||
<!-- 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">
|
||||
<template #icon>
|
||||
<RefreshIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.retry }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</div>
|
||||
|
||||
<!-- Reply form (moderators can reply even when locked) -->
|
||||
<PostReplyForm
|
||||
v-if="!loading && !error && thread && (!thread.isLocked || canModerate)"
|
||||
ref="replyForm"
|
||||
@submit="handleSubmitReply"
|
||||
@cancel="handleCancelReply"
|
||||
/>
|
||||
</div>
|
||||
<!-- 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">
|
||||
<PinIcon :size="20" />
|
||||
</span>
|
||||
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
|
||||
<LockIcon :size="20" />
|
||||
</span>
|
||||
{{ thread.title }}
|
||||
</h2>
|
||||
<div class="thread-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">{{ strings.by }}</span>
|
||||
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
|
||||
{{ thread.authorDisplayName || 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">
|
||||
<EyeIcon :size="16" />
|
||||
</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"
|
||||
:ref="(el) => setPostCardRef(el, post.id)"
|
||||
:post="post"
|
||||
:is-first-post="index === 0"
|
||||
:is-unread="isPostUnread(post)"
|
||||
@reply="handleReply"
|
||||
@update="handleUpdate"
|
||||
@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 @click="replyToThread" variant="primary">
|
||||
<template #icon>
|
||||
<ReplyIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.reply }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Locked message (only shown to non-moderators) -->
|
||||
<div
|
||||
v-if="!loading && !error && thread && thread.isLocked && !canModerate"
|
||||
class="locked-message mt-16"
|
||||
>
|
||||
<NcEmptyContent :title="strings.locked" :description="strings.lockedMessage">
|
||||
<template #icon>
|
||||
<LockIcon :size="64" />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</div>
|
||||
|
||||
<!-- Reply form (moderators can reply even when locked) -->
|
||||
<PostReplyForm
|
||||
v-if="!loading && !error && thread && (!thread.isLocked || canModerate)"
|
||||
ref="replyForm"
|
||||
@submit="handleSubmitReply"
|
||||
@cancel="handleCancelReply"
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -200,6 +203,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PostCard from '@/components/PostCard.vue'
|
||||
import PostReplyForm from '@/components/PostReplyForm.vue'
|
||||
import PinIcon from '@icons/Pin.vue'
|
||||
@@ -227,6 +231,7 @@ export default defineComponent({
|
||||
NcLoadingIcon,
|
||||
NcDateTime,
|
||||
AppToolbar,
|
||||
PageWrapper,
|
||||
PostCard,
|
||||
PostReplyForm,
|
||||
PinIcon,
|
||||
@@ -285,7 +290,7 @@ export default defineComponent({
|
||||
threadUnlocked: t('forum', 'Thread unlocked'),
|
||||
threadPinned: t('forum', 'Thread pinned'),
|
||||
threadUnpinned: t('forum', 'Thread unpinned'),
|
||||
subscribe: t('forum', 'Subscribe to thread'),
|
||||
subscribe: t('forum', 'Subscribe'),
|
||||
subscribed: t('forum', 'Subscribed'),
|
||||
threadSubscribed: t('forum', 'Subscribed to thread'),
|
||||
threadUnsubscribed: t('forum', 'Unsubscribed from thread'),
|
||||
@@ -727,15 +732,15 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.icon-label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.thread-view {
|
||||
margin-bottom: 3rem;
|
||||
|
||||
.icon-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
|
||||
282
src/views/UserPreferencesView.vue
Normal file
282
src/views/UserPreferencesView.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<PageWrapper>
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<div class="user-preferences-view">
|
||||
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<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="loadPreferences">{{ strings.retry }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Preferences form -->
|
||||
<div v-else class="preferences-form">
|
||||
<!-- Thread Subscriptions Section -->
|
||||
<div class="form-section">
|
||||
<h3>{{ strings.subscriptionsTitle }}</h3>
|
||||
<p class="section-description muted">{{ strings.subscriptionsDesc }}</p>
|
||||
|
||||
<div class="preference-item">
|
||||
<NcCheckboxRadioSwitch v-model="formData.auto_subscribe_created_threads">
|
||||
{{ strings.autoSubscribeLabel }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="preference-hint">{{ strings.autoSubscribeHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<NcButton variant="primary" :disabled="saving || !hasChanges" @click="savePreferences">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="saving" :size="20" />
|
||||
<CheckIcon v-else :size="20" />
|
||||
</template>
|
||||
{{ strings.save }}
|
||||
</NcButton>
|
||||
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Success message -->
|
||||
<div v-if="saveSuccess" class="success-message">
|
||||
<CheckIcon :size="20" />
|
||||
<span>{{ strings.saveSuccess }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import CheckIcon from '@icons/Check.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
interface UserPreferences {
|
||||
auto_subscribe_created_threads: boolean
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'UserPreferencesView',
|
||||
components: {
|
||||
NcButton,
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
NcCheckboxRadioSwitch,
|
||||
AppToolbar,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
saveSuccess: false,
|
||||
error: null as string | null,
|
||||
originalData: {
|
||||
auto_subscribe_created_threads: true,
|
||||
} as UserPreferences,
|
||||
formData: {
|
||||
auto_subscribe_created_threads: true,
|
||||
} as UserPreferences,
|
||||
|
||||
strings: {
|
||||
title: t('forum', 'Preferences'),
|
||||
subtitle: t('forum', 'Customize your forum experience'),
|
||||
back: t('forum', 'Back'),
|
||||
loading: t('forum', 'Loading preferences…'),
|
||||
errorTitle: t('forum', 'Error loading preferences'),
|
||||
retry: t('forum', 'Retry'),
|
||||
subscriptionsTitle: t('forum', 'Notifications'),
|
||||
subscriptionsDesc: t('forum', 'Configure how you receive notifications'),
|
||||
autoSubscribeLabel: t('forum', 'Auto-subscribe to threads I create'),
|
||||
autoSubscribeHint: t(
|
||||
'forum',
|
||||
'When enabled, you will automatically receive notifications for replies to threads you create',
|
||||
),
|
||||
save: t('forum', 'Save'),
|
||||
cancel: t('forum', 'Cancel'),
|
||||
saveSuccess: t('forum', 'Preferences saved successfully'),
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasChanges(): boolean {
|
||||
return (
|
||||
this.formData.auto_subscribe_created_threads !==
|
||||
this.originalData.auto_subscribe_created_threads
|
||||
)
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.loadPreferences()
|
||||
},
|
||||
methods: {
|
||||
async loadPreferences(): Promise<void> {
|
||||
try {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
const response = await ocs.get<UserPreferences>('/user-preferences')
|
||||
this.originalData = { ...response.data }
|
||||
this.formData = { ...response.data }
|
||||
} catch (e) {
|
||||
console.error('Failed to load preferences', e)
|
||||
this.error = (e as Error).message || t('forum', 'An unexpected error occurred')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async savePreferences(): Promise<void> {
|
||||
try {
|
||||
this.saving = true
|
||||
this.saveSuccess = false
|
||||
|
||||
await ocs.put('/user-preferences', this.formData)
|
||||
|
||||
this.originalData = { ...this.formData }
|
||||
this.saveSuccess = true
|
||||
|
||||
// Hide success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.saveSuccess = false
|
||||
}, 3000)
|
||||
} catch (e) {
|
||||
console.error('Failed to save preferences', e)
|
||||
this.error = (e as Error).message || t('forum', 'Failed to save preferences')
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
resetForm(): void {
|
||||
this.formData = { ...this.originalData }
|
||||
this.saveSuccess = false
|
||||
},
|
||||
|
||||
goBack(): void {
|
||||
this.$router.back()
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-preferences-view {
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.preferences-form {
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 32px;
|
||||
padding: 24px;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.preference-item {
|
||||
padding: 12px 0;
|
||||
|
||||
.preference-hint {
|
||||
margin: 8px 0 0 32px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success-dark);
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,255 +1,257 @@
|
||||
<template>
|
||||
<div class="admin-bbcode-list">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<p class="muted">{{ strings.subtitle }}</p>
|
||||
<PageWrapper>
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #right>
|
||||
<NcButton @click="showHelp = true">
|
||||
<template #icon>
|
||||
<HelpCircleIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.help }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" @click="createBBCode">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createBBCode }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<div class="admin-bbcode-list">
|
||||
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
|
||||
|
||||
<!-- BBCode Help Dialog -->
|
||||
<BBCodeHelpDialog v-model:open="showHelp" :show-custom="false" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NcButton @click="showHelp = true">
|
||||
<template #icon>
|
||||
<HelpCircleIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.help }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" @click="createBBCode">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createBBCode }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BBCode Help Dialog -->
|
||||
<BBCodeHelpDialog v-model:open="showHelp" :show-custom="false" />
|
||||
<!-- 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>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
<!-- BBCode list -->
|
||||
<div v-else class="bbcode-list">
|
||||
<!-- Enabled BBCodes Section -->
|
||||
<section class="bbcodes-section">
|
||||
<div class="section-header">
|
||||
<h3>{{ strings.enabledTitle }}</h3>
|
||||
<p class="muted">{{ strings.enabledSubtitle }}</p>
|
||||
</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>
|
||||
|
||||
<!-- BBCode list -->
|
||||
<div v-else class="bbcode-list">
|
||||
<!-- Enabled BBCodes Section -->
|
||||
<section class="bbcodes-section">
|
||||
<div class="section-header">
|
||||
<h3>{{ strings.enabledTitle }}</h3>
|
||||
<p class="muted">{{ strings.enabledSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="enabledBBCodes.length > 0" class="bbcodes-table">
|
||||
<div v-for="bbcode in enabledBBCodes" :key="`bbcode-${bbcode.id}`" class="bbcode-row">
|
||||
<div class="bbcode-info">
|
||||
<div class="bbcode-header">
|
||||
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
|
||||
<div v-if="bbcode.parseInner" class="badge badge-info">
|
||||
{{ strings.parseInner }}
|
||||
<div v-if="enabledBBCodes.length > 0" class="bbcodes-table">
|
||||
<div v-for="bbcode in enabledBBCodes" :key="`bbcode-${bbcode.id}`" class="bbcode-row">
|
||||
<div class="bbcode-info">
|
||||
<div class="bbcode-header">
|
||||
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
|
||||
<div v-if="bbcode.parseInner" class="badge badge-info">
|
||||
{{ strings.parseInner }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bbcode.description" class="bbcode-desc muted">
|
||||
{{ bbcode.description }}
|
||||
</div>
|
||||
<div class="bbcode-replacement">
|
||||
<span class="label muted">{{ strings.replacement }}:</span>
|
||||
<code>{{ bbcode.replacement }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bbcode.description" class="bbcode-desc muted">
|
||||
{{ bbcode.description }}
|
||||
<div class="bbcode-actions">
|
||||
<NcButton @click="editBBCode(bbcode)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcButton>
|
||||
<NcButton @click="toggleEnabled(bbcode)">
|
||||
<template #icon>
|
||||
<EyeOffIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.disable }}
|
||||
</NcButton>
|
||||
<NcButton variant="error" @click="confirmDelete(bbcode)">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.delete }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<div class="bbcode-replacement">
|
||||
<span class="label muted">{{ strings.replacement }}:</span>
|
||||
<code>{{ bbcode.replacement }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bbcode-actions">
|
||||
<NcButton @click="editBBCode(bbcode)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcButton>
|
||||
<NcButton @click="toggleEnabled(bbcode)">
|
||||
<template #icon>
|
||||
<EyeOffIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.disable }}
|
||||
</NcButton>
|
||||
<NcButton variant="error" @click="confirmDelete(bbcode)">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.delete }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-bbcodes muted">
|
||||
{{ strings.noEnabledBBCodes }}
|
||||
</div>
|
||||
</section>
|
||||
<div v-else class="no-bbcodes muted">
|
||||
{{ strings.noEnabledBBCodes }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Disabled BBCodes Section -->
|
||||
<section v-if="disabledBBCodes.length > 0" class="bbcodes-section">
|
||||
<div class="section-header">
|
||||
<h3>{{ strings.disabledTitle }}</h3>
|
||||
<p class="muted">{{ strings.disabledSubtitle }}</p>
|
||||
<!-- Disabled BBCodes Section -->
|
||||
<section v-if="disabledBBCodes.length > 0" class="bbcodes-section">
|
||||
<div class="section-header">
|
||||
<h3>{{ strings.disabledTitle }}</h3>
|
||||
<p class="muted">{{ strings.disabledSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bbcodes-table">
|
||||
<div
|
||||
v-for="bbcode in disabledBBCodes"
|
||||
:key="`bbcode-${bbcode.id}`"
|
||||
class="bbcode-row disabled"
|
||||
>
|
||||
<div class="bbcode-info">
|
||||
<div class="bbcode-header">
|
||||
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
|
||||
<div v-if="bbcode.parseInner" class="badge badge-info">
|
||||
{{ strings.parseInner }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bbcode.description" class="bbcode-desc muted">
|
||||
{{ bbcode.description }}
|
||||
</div>
|
||||
<div class="bbcode-replacement">
|
||||
<span class="label muted">{{ strings.replacement }}:</span>
|
||||
<code>{{ bbcode.replacement }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bbcode-actions">
|
||||
<NcButton @click="editBBCode(bbcode)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" @click="toggleEnabled(bbcode)">
|
||||
<template #icon>
|
||||
<EyeIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.enable }}
|
||||
</NcButton>
|
||||
<NcButton variant="error" @click="confirmDelete(bbcode)">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.delete }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<NcDialog
|
||||
v-if="deleteDialog.show"
|
||||
:name="strings.deleteDialogTitle"
|
||||
@close="deleteDialog.show = false"
|
||||
>
|
||||
<div class="delete-dialog-content">
|
||||
<p>{{ strings.deleteConfirmMessage(deleteDialog.bbcode?.tag || '') }}</p>
|
||||
<p class="muted">{{ strings.deleteWarning }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bbcodes-table">
|
||||
<div
|
||||
v-for="bbcode in disabledBBCodes"
|
||||
:key="`bbcode-${bbcode.id}`"
|
||||
class="bbcode-row disabled"
|
||||
<template #actions>
|
||||
<NcButton @click="deleteDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton variant="error" @click="executeDelete">
|
||||
{{ strings.deleteBBCode }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<!-- BBCode Edit/Create Dialog -->
|
||||
<NcDialog
|
||||
v-if="editDialog.show"
|
||||
:name="editDialog.isEditing ? strings.editBBCodeTitle : strings.createBBCodeTitle"
|
||||
@close="editDialog.show = false"
|
||||
>
|
||||
<div class="bbcode-dialog-content">
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="editDialog.tag"
|
||||
:label="strings.tag"
|
||||
:placeholder="strings.tagPlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.tagHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="editDialog.replacement"
|
||||
:label="strings.replacementLabel"
|
||||
:placeholder="strings.replacementPlaceholder"
|
||||
:rows="3"
|
||||
:required="true"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.replacementHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="editDialog.example"
|
||||
:label="strings.exampleLabel"
|
||||
:placeholder="strings.examplePlaceholder"
|
||||
:rows="2"
|
||||
:required="true"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.exampleHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="editDialog.description"
|
||||
:label="strings.description"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
:rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcCheckboxRadioSwitch v-model="editDialog.enabled" type="switch">
|
||||
{{ strings.enabledLabel }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcCheckboxRadioSwitch v-model="editDialog.parseInner" type="switch">
|
||||
{{ strings.parseInnerLabel }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="help-text muted">{{ strings.parseInnerHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="editDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="primary"
|
||||
:disabled="
|
||||
!editDialog.tag.trim() || !editDialog.replacement.trim() || !editDialog.example.trim()
|
||||
"
|
||||
@click="saveBBCode"
|
||||
>
|
||||
<div class="bbcode-info">
|
||||
<div class="bbcode-header">
|
||||
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
|
||||
<div v-if="bbcode.parseInner" class="badge badge-info">
|
||||
{{ strings.parseInner }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="bbcode.description" class="bbcode-desc muted">
|
||||
{{ bbcode.description }}
|
||||
</div>
|
||||
<div class="bbcode-replacement">
|
||||
<span class="label muted">{{ strings.replacement }}:</span>
|
||||
<code>{{ bbcode.replacement }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bbcode-actions">
|
||||
<NcButton @click="editBBCode(bbcode)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" @click="toggleEnabled(bbcode)">
|
||||
<template #icon>
|
||||
<EyeIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.enable }}
|
||||
</NcButton>
|
||||
<NcButton variant="error" @click="confirmDelete(bbcode)">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.delete }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<template v-if="editDialog.submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ editDialog.isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<NcDialog
|
||||
v-if="deleteDialog.show"
|
||||
:name="strings.deleteDialogTitle"
|
||||
@close="deleteDialog.show = false"
|
||||
>
|
||||
<div class="delete-dialog-content">
|
||||
<p>{{ strings.deleteConfirmMessage(deleteDialog.bbcode?.tag || '') }}</p>
|
||||
<p class="muted">{{ strings.deleteWarning }}</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="deleteDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton variant="error" @click="executeDelete">
|
||||
{{ strings.deleteBBCode }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<!-- BBCode Edit/Create Dialog -->
|
||||
<NcDialog
|
||||
v-if="editDialog.show"
|
||||
:name="editDialog.isEditing ? strings.editBBCodeTitle : strings.createBBCodeTitle"
|
||||
@close="editDialog.show = false"
|
||||
>
|
||||
<div class="bbcode-dialog-content">
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="editDialog.tag"
|
||||
:label="strings.tag"
|
||||
:placeholder="strings.tagPlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.tagHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="editDialog.replacement"
|
||||
:label="strings.replacementLabel"
|
||||
:placeholder="strings.replacementPlaceholder"
|
||||
:rows="3"
|
||||
:required="true"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.replacementHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="editDialog.example"
|
||||
:label="strings.exampleLabel"
|
||||
:placeholder="strings.examplePlaceholder"
|
||||
:rows="2"
|
||||
:required="true"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.exampleHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="editDialog.description"
|
||||
:label="strings.description"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
:rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcCheckboxRadioSwitch v-model="editDialog.enabled" type="switch">
|
||||
{{ strings.enabledLabel }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcCheckboxRadioSwitch v-model="editDialog.parseInner" type="switch">
|
||||
{{ strings.parseInnerLabel }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="help-text muted">{{ strings.parseInnerHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="editDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="primary"
|
||||
:disabled="
|
||||
!editDialog.tag.trim() || !editDialog.replacement.trim() || !editDialog.example.trim()
|
||||
"
|
||||
@click="saveBBCode"
|
||||
>
|
||||
<template v-if="editDialog.submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ editDialog.isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -268,6 +270,9 @@ import EyeIcon from '@icons/Eye.vue'
|
||||
import EyeOffIcon from '@icons/EyeOff.vue'
|
||||
import HelpCircleIcon from '@icons/HelpCircle.vue'
|
||||
import BBCodeHelpDialog from '@/components/BBCodeHelpDialog.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
@@ -292,6 +297,9 @@ export default defineComponent({
|
||||
NcLoadingIcon,
|
||||
NcTextField,
|
||||
NcTextArea,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
AppToolbar,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
DeleteIcon,
|
||||
@@ -496,8 +504,6 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-bbcode-list {
|
||||
max-width: 1200px;
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
@@ -517,22 +523,6 @@ export default defineComponent({
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.bbcode-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,192 +1,199 @@
|
||||
<template>
|
||||
<div class="admin-category-edit">
|
||||
<div class="page-header">
|
||||
<div class="header-actions">
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
<PageWrapper>
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<div class="admin-category-edit">
|
||||
<PageHeader
|
||||
:title="isEditing ? strings.editCategory : strings.createCategory"
|
||||
:subtitle="strings.subtitle"
|
||||
/>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ isEditing ? strings.editCategory : strings.createCategory }}</h2>
|
||||
<p class="muted">{{ strings.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Form -->
|
||||
<div v-else class="category-form">
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.basicInfo }}</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>{{ strings.categoryHeader }} *</label>
|
||||
<div class="header-select-row">
|
||||
<NcSelect
|
||||
v-model="selectedHeader"
|
||||
:options="headerOptions"
|
||||
:placeholder="strings.selectHeader"
|
||||
label="label"
|
||||
track-by="id"
|
||||
class="header-select"
|
||||
/>
|
||||
<NcButton @click="createNewHeader">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.newHeader }}
|
||||
</NcButton>
|
||||
<NcButton v-if="selectedHeader" @click="editHeader">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.editHeader }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div v-else class="category-form">
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.basicInfo }}</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>{{ strings.categoryHeader }} *</label>
|
||||
<div class="header-select-row">
|
||||
<NcSelect
|
||||
v-model="selectedHeader"
|
||||
:options="headerOptions"
|
||||
:placeholder="strings.selectHeader"
|
||||
label="label"
|
||||
track-by="id"
|
||||
class="header-select"
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="formData.name"
|
||||
:label="strings.name"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="formData.slug"
|
||||
:label="strings.slug"
|
||||
:placeholder="strings.slugPlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.slugHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="formData.description"
|
||||
:label="strings.description"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
:rows="3"
|
||||
/>
|
||||
<NcButton @click="createNewHeader">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.newHeader }}
|
||||
</NcButton>
|
||||
<NcButton v-if="selectedHeader" @click="editHeader">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.editHeader }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="formData.name"
|
||||
:label="strings.name"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
<!-- Permissions Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.permissions }}</h3>
|
||||
<p class="muted">{{ strings.permissionsDescription }}</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>{{ strings.viewRoles }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedViewRoles"
|
||||
:options="roleOptions"
|
||||
:placeholder="strings.selectRoles"
|
||||
label="label"
|
||||
track-by="id"
|
||||
:multiple="true"
|
||||
:taggable="false"
|
||||
:close-on-select="false"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.viewRolesHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ strings.moderateRoles }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedModerateRoles"
|
||||
:options="roleOptions"
|
||||
:placeholder="strings.selectRoles"
|
||||
label="label"
|
||||
track-by="id"
|
||||
:multiple="true"
|
||||
:taggable="false"
|
||||
:close-on-select="false"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.moderateRolesHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
|
||||
<template v-if="submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Edit/Create Dialog -->
|
||||
<NcDialog
|
||||
v-if="headerDialog.show"
|
||||
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
|
||||
@close="headerDialog.show = false"
|
||||
>
|
||||
<div class="header-dialog-content">
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="formData.slug"
|
||||
:label="strings.slug"
|
||||
:placeholder="strings.slugPlaceholder"
|
||||
v-model="headerDialog.name"
|
||||
:label="strings.headerName"
|
||||
:placeholder="strings.headerNamePlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.slugHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="formData.description"
|
||||
:label="strings.description"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
:rows="3"
|
||||
v-model="headerDialog.description"
|
||||
:label="strings.headerDescription"
|
||||
:placeholder="strings.headerDescriptionPlaceholder"
|
||||
:rows="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Permissions Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.permissions }}</h3>
|
||||
<p class="muted">{{ strings.permissionsDescription }}</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>{{ strings.viewRoles }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedViewRoles"
|
||||
:options="roleOptions"
|
||||
:placeholder="strings.selectRoles"
|
||||
label="label"
|
||||
track-by="id"
|
||||
:multiple="true"
|
||||
:taggable="false"
|
||||
:close-on-select="false"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.viewRolesHelp }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ strings.moderateRoles }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedModerateRoles"
|
||||
:options="roleOptions"
|
||||
:placeholder="strings.selectRoles"
|
||||
label="label"
|
||||
track-by="id"
|
||||
:multiple="true"
|
||||
:taggable="false"
|
||||
:close-on-select="false"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.moderateRolesHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
|
||||
<template v-if="submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</div>
|
||||
<template #actions>
|
||||
<NcButton @click="headerDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
|
||||
<template v-if="headerDialog.submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ headerDialog.isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
|
||||
<!-- Header Edit/Create Dialog -->
|
||||
<NcDialog
|
||||
v-if="headerDialog.show"
|
||||
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
|
||||
@close="headerDialog.show = false"
|
||||
>
|
||||
<div class="header-dialog-content">
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="headerDialog.name"
|
||||
:label="strings.headerName"
|
||||
:placeholder="strings.headerNamePlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="headerDialog.description"
|
||||
:label="strings.headerDescription"
|
||||
:placeholder="strings.headerDescriptionPlaceholder"
|
||||
:rows="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="headerDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
|
||||
<template v-if="headerDialog.submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ headerDialog.isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
@@ -211,6 +218,8 @@ export default defineComponent({
|
||||
NcSelect,
|
||||
NcTextField,
|
||||
NcTextArea,
|
||||
PageWrapper,
|
||||
AppToolbar,
|
||||
ArrowLeftIcon,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
@@ -559,8 +568,6 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-category-edit {
|
||||
max-width: 800px;
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
@@ -583,10 +590,6 @@ export default defineComponent({
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-actions {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
@@ -1,112 +1,56 @@
|
||||
<template>
|
||||
<div class="admin-category-list">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<p class="muted">{{ strings.subtitle }}</p>
|
||||
<PageWrapper>
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #right>
|
||||
<NcButton @click="createHeader">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createHeader }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" @click="createCategory">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createCategory }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<div class="admin-category-list">
|
||||
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NcButton @click="createHeader">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createHeader }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" @click="createCategory">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createCategory }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Category list -->
|
||||
<div v-else class="category-list">
|
||||
<!-- Categories by Header -->
|
||||
<section class="categories-section">
|
||||
<template v-for="(header, headerIndex) in categoryHeaders" :key="header.id">
|
||||
<div class="header-row">
|
||||
<div class="header-sort-buttons">
|
||||
<NcButton
|
||||
v-if="headerIndex > 0"
|
||||
variant="tertiary"
|
||||
@click="moveHeaderUp(headerIndex)"
|
||||
:aria-label="strings.moveUp"
|
||||
:title="strings.moveUp"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronUpIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="headerIndex < categoryHeaders.length - 1"
|
||||
variant="tertiary"
|
||||
@click="moveHeaderDown(headerIndex)"
|
||||
:aria-label="strings.moveDown"
|
||||
:title="strings.moveDown"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronDownIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<h3>{{ header.name }}</h3>
|
||||
<span v-if="header.description" class="muted">{{ header.description }}</span>
|
||||
<span class="muted category-count"
|
||||
>{{ header.categories?.length || 0 }} {{ strings.categoriesCount }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<NcButton @click="editHeaderById(header.id)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="error"
|
||||
:disabled="categoryHeaders.length <= 1"
|
||||
@click="confirmDeleteHeader(header)"
|
||||
>
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.delete }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
|
||||
<div
|
||||
v-for="(category, index) in header.categories"
|
||||
:key="category.id"
|
||||
class="category-row"
|
||||
>
|
||||
<div class="category-sort-buttons">
|
||||
<!-- Category list -->
|
||||
<div v-else class="category-list">
|
||||
<!-- Categories by Header -->
|
||||
<section class="categories-section">
|
||||
<template v-for="(header, headerIndex) in categoryHeaders" :key="header.id">
|
||||
<div class="header-row">
|
||||
<div class="header-sort-buttons">
|
||||
<NcButton
|
||||
v-if="index > 0"
|
||||
v-if="headerIndex > 0"
|
||||
variant="tertiary"
|
||||
@click="moveCategoryUp(header.id, index)"
|
||||
@click="moveHeaderUp(headerIndex)"
|
||||
:aria-label="strings.moveUp"
|
||||
:title="strings.moveUp"
|
||||
>
|
||||
@@ -115,9 +59,9 @@
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="index < header.categories.length - 1"
|
||||
v-if="headerIndex < categoryHeaders.length - 1"
|
||||
variant="tertiary"
|
||||
@click="moveCategoryDown(header.id, index)"
|
||||
@click="moveHeaderDown(headerIndex)"
|
||||
:aria-label="strings.moveDown"
|
||||
:title="strings.moveDown"
|
||||
>
|
||||
@@ -126,27 +70,25 @@
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<div class="category-name">{{ category.name }}</div>
|
||||
<div v-if="category.description" class="category-desc muted">
|
||||
{{ category.description }}
|
||||
</div>
|
||||
<div class="category-meta muted">
|
||||
<span>Slug: {{ category.slug }}</span>
|
||||
<span>•</span>
|
||||
<span>Threads: {{ category.threadCount || 0 }}</span>
|
||||
<span>•</span>
|
||||
<span>Posts: {{ category.postCount || 0 }}</span>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<h3>{{ header.name }}</h3>
|
||||
<span v-if="header.description" class="muted">{{ header.description }}</span>
|
||||
<span class="muted category-count"
|
||||
>{{ header.categories?.length || 0 }} {{ strings.categoriesCount }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="category-actions">
|
||||
<NcButton @click="editCategory(category.id)">
|
||||
<div class="header-actions">
|
||||
<NcButton @click="editHeaderById(header.id)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcButton>
|
||||
<NcButton variant="error" @click="confirmDelete(category)">
|
||||
<NcButton
|
||||
variant="error"
|
||||
:disabled="categoryHeaders.length <= 1"
|
||||
@click="confirmDeleteHeader(header)"
|
||||
>
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
@@ -154,201 +96,264 @@
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-categories muted">
|
||||
{{ strings.noCategories }}
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<NcDialog
|
||||
v-if="deleteDialog.show"
|
||||
:name="strings.deleteDialogTitle"
|
||||
@close="deleteDialog.show = false"
|
||||
>
|
||||
<div class="delete-dialog-content">
|
||||
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
|
||||
|
||||
<div v-if="deleteDialog.threadCount > 0" class="thread-warning">
|
||||
<InformationIcon :size="20" />
|
||||
<span>{{ strings.threadWarning(deleteDialog.threadCount) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="deleteDialog.threadCount > 0" class="migration-options">
|
||||
<h4>{{ strings.whatToDoWithThreads }}</h4>
|
||||
|
||||
<div class="radio-group">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="deleteDialog.action"
|
||||
value="migrate"
|
||||
type="radio"
|
||||
name="delete-action"
|
||||
>
|
||||
{{ strings.migrateThreads }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
|
||||
<label>{{ strings.selectTargetCategory }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedTargetCategory"
|
||||
:options="targetCategoryOptions"
|
||||
:placeholder="strings.selectCategory"
|
||||
label="label"
|
||||
track-by="id"
|
||||
/>
|
||||
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
|
||||
<div
|
||||
v-for="(category, index) in header.categories"
|
||||
:key="category.id"
|
||||
class="category-row"
|
||||
>
|
||||
<div class="category-sort-buttons">
|
||||
<NcButton
|
||||
v-if="index > 0"
|
||||
variant="tertiary"
|
||||
@click="moveCategoryUp(header.id, index)"
|
||||
:aria-label="strings.moveUp"
|
||||
:title="strings.moveUp"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronUpIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="index < header.categories.length - 1"
|
||||
variant="tertiary"
|
||||
@click="moveCategoryDown(header.id, index)"
|
||||
:aria-label="strings.moveDown"
|
||||
:title="strings.moveDown"
|
||||
>
|
||||
<template #icon>
|
||||
<ChevronDownIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<div class="category-name">{{ category.name }}</div>
|
||||
<div v-if="category.description" class="category-desc muted">
|
||||
{{ category.description }}
|
||||
</div>
|
||||
<div class="category-meta muted">
|
||||
<span>Slug: {{ category.slug }}</span>
|
||||
<span>•</span>
|
||||
<span>Threads: {{ category.threadCount || 0 }}</span>
|
||||
<span>•</span>
|
||||
<span>Posts: {{ category.postCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="category-actions">
|
||||
<NcButton @click="editCategory(category.id)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcButton>
|
||||
<NcButton variant="error" @click="confirmDelete(category)">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.delete }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-categories muted">
|
||||
{{ strings.noCategories }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="radio-group">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="deleteDialog.action"
|
||||
value="delete"
|
||||
type="radio"
|
||||
name="delete-action"
|
||||
>
|
||||
{{ strings.softDeleteThreads }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="deleteDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="error"
|
||||
:disabled="deleteDialog.action === 'migrate' && !selectedTargetCategory"
|
||||
@click="executeDelete"
|
||||
>
|
||||
{{ strings.deleteCategory }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Header Edit/Create Dialog -->
|
||||
<NcDialog
|
||||
v-if="headerDialog.show"
|
||||
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
|
||||
@close="headerDialog.show = false"
|
||||
>
|
||||
<div class="header-dialog-content">
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="headerDialog.name"
|
||||
:label="strings.headerName"
|
||||
:placeholder="strings.headerNamePlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="headerDialog.description"
|
||||
:label="strings.headerDescription"
|
||||
:placeholder="strings.headerDescriptionPlaceholder"
|
||||
:rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model.number="headerDialog.sortOrder"
|
||||
:label="strings.headerSortOrder"
|
||||
:placeholder="strings.sortOrderPlaceholder"
|
||||
type="number"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="headerDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
|
||||
<template v-if="headerDialog.submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ headerDialog.isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Header Delete Confirmation Dialog -->
|
||||
<NcDialog
|
||||
v-if="deleteHeaderDialog.show"
|
||||
:name="strings.deleteHeaderTitle"
|
||||
@close="deleteHeaderDialog.show = false"
|
||||
>
|
||||
<div class="delete-dialog-content">
|
||||
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
|
||||
|
||||
<div v-if="deleteHeaderDialog.categoryCount > 0" class="thread-warning">
|
||||
<InformationIcon :size="20" />
|
||||
<span>{{ strings.headerCategoryWarning(deleteHeaderDialog.categoryCount) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="deleteHeaderDialog.categoryCount > 0" class="migration-options">
|
||||
<h4>{{ strings.whatToDoWithCategories }}</h4>
|
||||
|
||||
<div class="radio-group">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="deleteHeaderDialog.action"
|
||||
value="migrate"
|
||||
type="radio"
|
||||
name="delete-header-action"
|
||||
>
|
||||
{{ strings.migrateCategories }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
|
||||
<label>{{ strings.selectTargetHeader }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedTargetHeader"
|
||||
:options="targetHeaderOptions"
|
||||
:placeholder="strings.selectHeader"
|
||||
label="label"
|
||||
track-by="id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="radio-group">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="deleteHeaderDialog.action"
|
||||
value="delete"
|
||||
type="radio"
|
||||
name="delete-header-action"
|
||||
>
|
||||
{{ strings.deleteCategories }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="deleteHeaderDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="error"
|
||||
:disabled="deleteHeaderDialog.action === 'migrate' && !selectedTargetHeader"
|
||||
@click="executeDeleteHeader"
|
||||
>
|
||||
{{ strings.deleteHeader }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
<!-- Delete confirmation dialog -->
|
||||
<NcDialog
|
||||
v-if="deleteDialog.show"
|
||||
:name="strings.deleteDialogTitle"
|
||||
@close="deleteDialog.show = false"
|
||||
>
|
||||
<div class="delete-dialog-content">
|
||||
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
|
||||
|
||||
<div v-if="deleteDialog.threadCount > 0" class="thread-warning">
|
||||
<InformationIcon :size="20" />
|
||||
<span>{{ strings.threadWarning(deleteDialog.threadCount) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="deleteDialog.threadCount > 0" class="migration-options">
|
||||
<h4>{{ strings.whatToDoWithThreads }}</h4>
|
||||
|
||||
<div class="radio-group">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="deleteDialog.action"
|
||||
value="migrate"
|
||||
type="radio"
|
||||
name="delete-action"
|
||||
>
|
||||
{{ strings.migrateThreads }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
|
||||
<label>{{ strings.selectTargetCategory }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedTargetCategory"
|
||||
:options="targetCategoryOptions"
|
||||
:placeholder="strings.selectCategory"
|
||||
label="label"
|
||||
track-by="id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="radio-group">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="deleteDialog.action"
|
||||
value="delete"
|
||||
type="radio"
|
||||
name="delete-action"
|
||||
>
|
||||
{{ strings.softDeleteThreads }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="deleteDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="error"
|
||||
:disabled="deleteDialog.action === 'migrate' && !selectedTargetCategory"
|
||||
@click="executeDelete"
|
||||
>
|
||||
{{ strings.deleteCategory }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Header Edit/Create Dialog -->
|
||||
<NcDialog
|
||||
v-if="headerDialog.show"
|
||||
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
|
||||
@close="headerDialog.show = false"
|
||||
>
|
||||
<div class="header-dialog-content">
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="headerDialog.name"
|
||||
:label="strings.headerName"
|
||||
:placeholder="strings.headerNamePlaceholder"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="headerDialog.description"
|
||||
:label="strings.headerDescription"
|
||||
:placeholder="strings.headerDescriptionPlaceholder"
|
||||
:rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model.number="headerDialog.sortOrder"
|
||||
:label="strings.headerSortOrder"
|
||||
:placeholder="strings.sortOrderPlaceholder"
|
||||
type="number"
|
||||
/>
|
||||
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="headerDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
|
||||
<template v-if="headerDialog.submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ headerDialog.isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Header Delete Confirmation Dialog -->
|
||||
<NcDialog
|
||||
v-if="deleteHeaderDialog.show"
|
||||
:name="strings.deleteHeaderTitle"
|
||||
@close="deleteHeaderDialog.show = false"
|
||||
>
|
||||
<div class="delete-dialog-content">
|
||||
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
|
||||
|
||||
<div v-if="deleteHeaderDialog.categoryCount > 0" class="thread-warning">
|
||||
<InformationIcon :size="20" />
|
||||
<span>{{ strings.headerCategoryWarning(deleteHeaderDialog.categoryCount) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="deleteHeaderDialog.categoryCount > 0" class="migration-options">
|
||||
<h4>{{ strings.whatToDoWithCategories }}</h4>
|
||||
|
||||
<div class="radio-group">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="deleteHeaderDialog.action"
|
||||
value="migrate"
|
||||
type="radio"
|
||||
name="delete-header-action"
|
||||
>
|
||||
{{ strings.migrateCategories }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
|
||||
<label>{{ strings.selectTargetHeader }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedTargetHeader"
|
||||
:options="targetHeaderOptions"
|
||||
:placeholder="strings.selectHeader"
|
||||
label="label"
|
||||
track-by="id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="radio-group">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="deleteHeaderDialog.action"
|
||||
value="delete"
|
||||
type="radio"
|
||||
name="delete-header-action"
|
||||
>
|
||||
{{ strings.deleteCategories }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<NcButton @click="deleteHeaderDialog.show = false">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="error"
|
||||
:disabled="deleteHeaderDialog.action === 'migrate' && !selectedTargetHeader"
|
||||
@click="executeDeleteHeader"
|
||||
>
|
||||
{{ strings.deleteHeader }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
@@ -370,6 +375,9 @@ import type { CategoryHeader, Category, CatHeader } from '@/types'
|
||||
export default defineComponent({
|
||||
name: 'AdminCategoryList',
|
||||
components: {
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
AppToolbar,
|
||||
NcButton,
|
||||
NcCheckboxRadioSwitch,
|
||||
NcDialog,
|
||||
@@ -753,8 +761,6 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-category-list {
|
||||
max-width: 1200px;
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
@@ -774,22 +780,6 @@ export default defineComponent({
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-list {
|
||||
.categories-section {
|
||||
display: flex;
|
||||
|
||||
@@ -1,139 +1,138 @@
|
||||
<template>
|
||||
<div class="admin-dashboard">
|
||||
<div class="page-header">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<p class="muted">{{ strings.subtitle }}</p>
|
||||
</div>
|
||||
<PageWrapper>
|
||||
<div class="admin-dashboard">
|
||||
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Dashboard content -->
|
||||
<div v-else-if="stats" class="dashboard-content">
|
||||
<!-- Totals section -->
|
||||
<section class="stats-section">
|
||||
<h3>{{ strings.totals }}</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<AccountMultipleIcon :size="32" />
|
||||
<!-- Dashboard content -->
|
||||
<div v-else-if="stats" class="dashboard-content">
|
||||
<!-- Totals section -->
|
||||
<section class="stats-section">
|
||||
<h3>{{ strings.totals }}</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<AccountMultipleIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totals.users }}</div>
|
||||
<div class="stat-label">{{ strings.totalUsers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totals.users }}</div>
|
||||
<div class="stat-label">{{ strings.totalUsers }}</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<ForumIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totals.threads }}</div>
|
||||
<div class="stat-label">{{ strings.totalThreads }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<MessageTextIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totals.posts }}</div>
|
||||
<div class="stat-label">{{ strings.totalPosts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<FolderIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totals.categories }}</div>
|
||||
<div class="stat-label">{{ strings.totalCategories }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<ForumIcon :size="32" />
|
||||
<!-- Recent activity section -->
|
||||
<section class="stats-section mt-24">
|
||||
<h3>{{ strings.recentActivity }}</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<AccountPlusIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.recent.users }}</div>
|
||||
<div class="stat-label">{{ strings.newUsers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totals.threads }}</div>
|
||||
<div class="stat-label">{{ strings.totalThreads }}</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<ForumIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.recent.threads }}</div>
|
||||
<div class="stat-label">{{ strings.newThreads }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<MessageTextIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.recent.posts }}</div>
|
||||
<div class="stat-label">{{ strings.newPosts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<MessageTextIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totals.posts }}</div>
|
||||
<div class="stat-label">{{ strings.totalPosts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<FolderIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.totals.categories }}</div>
|
||||
<div class="stat-label">{{ strings.totalCategories }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recent activity section -->
|
||||
<section class="stats-section mt-24">
|
||||
<h3>{{ strings.recentActivity }}</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<AccountPlusIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.recent.users }}</div>
|
||||
<div class="stat-label">{{ strings.newUsers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<ForumIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.recent.threads }}</div>
|
||||
<div class="stat-label">{{ strings.newThreads }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<MessageTextIcon :size="32" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.recent.posts }}</div>
|
||||
<div class="stat-label">{{ strings.newPosts }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Top contributors section -->
|
||||
<section class="stats-section mt-24">
|
||||
<h3>{{ strings.topContributors }}</h3>
|
||||
<div v-if="stats.topContributors.length > 0" class="contributors-list">
|
||||
<div
|
||||
v-for="(contributor, index) in stats.topContributors"
|
||||
:key="contributor.userId"
|
||||
class="contributor-item"
|
||||
>
|
||||
<div class="contributor-rank">{{ index + 1 }}</div>
|
||||
<UserInfo
|
||||
:user-id="contributor.userId"
|
||||
:display-name="contributor.userId"
|
||||
:avatar-size="40"
|
||||
<!-- Top contributors section -->
|
||||
<section class="stats-section mt-24">
|
||||
<h3>{{ strings.topContributors }}</h3>
|
||||
<div v-if="stats.topContributors.length > 0" class="contributors-list">
|
||||
<div
|
||||
v-for="(contributor, index) in stats.topContributors"
|
||||
:key="contributor.userId"
|
||||
class="contributor-item"
|
||||
>
|
||||
<template #meta>
|
||||
<div class="contributor-stats muted">
|
||||
{{ strings.postsCount(contributor.postCount) }}
|
||||
</div>
|
||||
</template>
|
||||
</UserInfo>
|
||||
<div class="contributor-rank">{{ index + 1 }}</div>
|
||||
<UserInfo
|
||||
:user-id="contributor.userId"
|
||||
:display-name="contributor.userId"
|
||||
:avatar-size="40"
|
||||
>
|
||||
<template #meta>
|
||||
<div class="contributor-stats muted">
|
||||
{{ strings.postsCount(contributor.postCount) }}
|
||||
</div>
|
||||
</template>
|
||||
</UserInfo>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">{{ strings.noContributors }}</div>
|
||||
</section>
|
||||
<div v-else class="muted">{{ strings.noContributors }}</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -142,6 +141,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import UserInfo from '@/components/UserInfo.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AccountMultipleIcon from '@icons/AccountMultiple.vue'
|
||||
import AccountPlusIcon from '@icons/AccountPlus.vue'
|
||||
import ForumIcon from '@icons/Forum.vue'
|
||||
@@ -175,6 +176,8 @@ export default defineComponent({
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
UserInfo,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
AccountMultipleIcon,
|
||||
AccountPlusIcon,
|
||||
ForumIcon,
|
||||
|
||||
@@ -1,79 +1,78 @@
|
||||
<template>
|
||||
<div class="admin-general-settings">
|
||||
<div class="page-header">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<p class="muted">{{ strings.subtitle }}</p>
|
||||
</div>
|
||||
<PageWrapper>
|
||||
<div class="admin-general-settings">
|
||||
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<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="loadSettings">{{ strings.retry }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
<!-- Error state -->
|
||||
<NcEmptyContent
|
||||
v-else-if="error"
|
||||
:title="strings.errorTitle"
|
||||
:description="error"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="loadSettings">{{ strings.retry }}</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- Settings form -->
|
||||
<div v-else class="settings-form">
|
||||
<div class="form-section">
|
||||
<h3>{{ strings.appearanceTitle }}</h3>
|
||||
<p class="muted">{{ strings.appearanceDesc }}</p>
|
||||
<!-- Settings form -->
|
||||
<div v-else class="settings-form">
|
||||
<div class="form-section">
|
||||
<h3>{{ strings.appearanceTitle }}</h3>
|
||||
<p class="muted">{{ strings.appearanceDesc }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="forum-title">{{ strings.forumTitle }}</label>
|
||||
<NcTextField
|
||||
id="forum-title"
|
||||
v-model.trim="formData.title"
|
||||
:placeholder="strings.forumTitlePlaceholder"
|
||||
:maxlength="100"
|
||||
/>
|
||||
<p class="hint">{{ strings.forumTitleHint }}</p>
|
||||
<div class="form-group">
|
||||
<label for="forum-title">{{ strings.forumTitle }}</label>
|
||||
<NcTextField
|
||||
id="forum-title"
|
||||
v-model.trim="formData.title"
|
||||
:placeholder="strings.forumTitlePlaceholder"
|
||||
:maxlength="100"
|
||||
/>
|
||||
<p class="hint">{{ strings.forumTitleHint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
|
||||
<NcTextArea
|
||||
id="forum-subtitle"
|
||||
v-model.trim="formData.subtitle"
|
||||
:placeholder="strings.forumSubtitlePlaceholder"
|
||||
:rows="3"
|
||||
:maxlength="500"
|
||||
/>
|
||||
<p class="hint">{{ strings.forumSubtitleHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
|
||||
<NcTextArea
|
||||
id="forum-subtitle"
|
||||
v-model.trim="formData.subtitle"
|
||||
:placeholder="strings.forumSubtitlePlaceholder"
|
||||
:rows="3"
|
||||
:maxlength="500"
|
||||
/>
|
||||
<p class="hint">{{ strings.forumSubtitleHint }}</p>
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<NcButton :disabled="saving || !hasChanges" @click="saveSettings">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="saving" :size="20" />
|
||||
<CheckIcon v-else :size="20" />
|
||||
</template>
|
||||
{{ strings.save }}
|
||||
</NcButton>
|
||||
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Success message -->
|
||||
<div v-if="saveSuccess" class="success-message">
|
||||
<CheckIcon :size="20" />
|
||||
<span>{{ strings.saveSuccess }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<NcButton :disabled="saving || !hasChanges" @click="saveSettings">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="saving" :size="20" />
|
||||
<CheckIcon v-else :size="20" />
|
||||
</template>
|
||||
{{ strings.save }}
|
||||
</NcButton>
|
||||
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
|
||||
{{ strings.cancel }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Success message -->
|
||||
<div v-if="saveSuccess" class="success-message">
|
||||
<CheckIcon :size="20" />
|
||||
<span>{{ strings.saveSuccess }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -83,6 +82,8 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import CheckIcon from '@icons/Check.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
@@ -100,6 +101,8 @@ export default defineComponent({
|
||||
NcLoadingIcon,
|
||||
NcTextField,
|
||||
NcTextArea,
|
||||
PageHeader,
|
||||
PageWrapper,
|
||||
CheckIcon,
|
||||
},
|
||||
data() {
|
||||
@@ -225,7 +228,6 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
max-width: 800px;
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
@@ -1,163 +1,168 @@
|
||||
<template>
|
||||
<div class="admin-role-edit">
|
||||
<div class="page-header">
|
||||
<div class="header-actions">
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
<PageWrapper>
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #left>
|
||||
<NcButton @click="goBack">
|
||||
<template #icon>
|
||||
<ArrowLeftIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.back }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<div class="admin-role-edit">
|
||||
<PageHeader
|
||||
:title="isEditing ? strings.editRole : strings.createRole"
|
||||
:subtitle="strings.subtitle"
|
||||
/>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ isEditing ? strings.editRole : strings.createRole }}</h2>
|
||||
<p class="muted">{{ strings.subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Form -->
|
||||
<div v-else class="role-form">
|
||||
<!-- Basic Info Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.basicInfo }}</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="formData.name"
|
||||
:label="strings.name"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
:disabled="isSystemRole"
|
||||
:required="true"
|
||||
/>
|
||||
<p v-if="isSystemRole" class="help-text muted">
|
||||
{{ strings.systemRoleNameWarning }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="formData.description"
|
||||
:label="strings.description"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Role Permissions Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.rolePermissions }}</h3>
|
||||
<p class="muted">{{ strings.rolePermissionsDesc }}</p>
|
||||
|
||||
<div class="permissions-checkboxes">
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools">
|
||||
<strong>{{ strings.canAccessAdminTools }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canEditRoles">
|
||||
<strong>{{ strings.canEditRoles }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canEditRolesDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canEditCategories">
|
||||
<strong>{{ strings.canEditCategories }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Category Permissions Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.categoryPermissions }}</h3>
|
||||
<p v-if="isAdmin" class="info-message">
|
||||
<InformationIcon :size="20" />
|
||||
{{ strings.adminFullAccess }}
|
||||
</p>
|
||||
<p v-else class="muted">{{ strings.categoryPermissionsDesc }}</p>
|
||||
|
||||
<div v-if="categoryHeaders.length > 0" class="permissions-table">
|
||||
<div class="table-header">
|
||||
<div class="col-category">{{ strings.category }}</div>
|
||||
<div class="col-permission">{{ strings.canView }}</div>
|
||||
<div class="col-permission">{{ strings.canModerate }}</div>
|
||||
</div>
|
||||
|
||||
<template v-for="header in categoryHeaders" :key="`header-${header.id}`">
|
||||
<!-- Header row -->
|
||||
<div class="table-header-row">
|
||||
<div class="header-name">{{ header.name }}</div>
|
||||
<!-- Form -->
|
||||
<div v-else class="role-form">
|
||||
<!-- Basic Info Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.basicInfo }}</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<NcTextField
|
||||
v-model="formData.name"
|
||||
:label="strings.name"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
:disabled="isSystemRole"
|
||||
:required="true"
|
||||
/>
|
||||
<p v-if="isSystemRole" class="help-text muted">
|
||||
{{ strings.systemRoleNameWarning }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Category rows under this header -->
|
||||
<div v-for="category in header.categories" :key="category.id" class="table-row">
|
||||
<div class="col-category">
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
<span v-if="category.description" class="category-desc muted">
|
||||
{{ category.description }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-permission">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="ensurePermission(category.id).canView"
|
||||
:disabled="isAdmin"
|
||||
>
|
||||
{{ strings.allow }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="col-permission">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="ensurePermission(category.id).canModerate"
|
||||
:disabled="isAdmin"
|
||||
>
|
||||
{{ strings.allow }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<NcTextArea
|
||||
v-model="formData.description"
|
||||
:label="strings.description"
|
||||
:placeholder="strings.descriptionPlaceholder"
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="muted">{{ strings.noCategories }}</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
|
||||
<template v-if="submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
<!-- Role Permissions Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.rolePermissions }}</h3>
|
||||
<p class="muted">{{ strings.rolePermissionsDesc }}</p>
|
||||
|
||||
<div class="permissions-checkboxes">
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools">
|
||||
<strong>{{ strings.canAccessAdminTools }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canEditRoles">
|
||||
<strong>{{ strings.canEditRoles }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canEditRolesDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<NcCheckboxRadioSwitch v-model="formData.canEditCategories">
|
||||
<strong>{{ strings.canEditCategories }}</strong>
|
||||
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Category Permissions Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.categoryPermissions }}</h3>
|
||||
<p v-if="isAdmin" class="info-message">
|
||||
<InformationIcon :size="20" />
|
||||
{{ strings.adminFullAccess }}
|
||||
</p>
|
||||
<p v-else class="muted">{{ strings.categoryPermissionsDesc }}</p>
|
||||
|
||||
<div v-if="categoryHeaders.length > 0" class="permissions-table">
|
||||
<div class="table-header">
|
||||
<div class="col-category">{{ strings.category }}</div>
|
||||
<div class="col-permission">{{ strings.canView }}</div>
|
||||
<div class="col-permission">{{ strings.canModerate }}</div>
|
||||
</div>
|
||||
|
||||
<template v-for="header in categoryHeaders" :key="`header-${header.id}`">
|
||||
<!-- Header row -->
|
||||
<div class="table-header-row">
|
||||
<div class="header-name">{{ header.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Category rows under this header -->
|
||||
<div v-for="category in header.categories" :key="category.id" class="table-row">
|
||||
<div class="col-category">
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
<span v-if="category.description" class="category-desc muted">
|
||||
{{ category.description }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-permission">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="ensurePermission(category.id).canView"
|
||||
:disabled="isAdmin"
|
||||
>
|
||||
{{ strings.allow }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
|
||||
<div class="col-permission">
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="ensurePermission(category.id).canModerate"
|
||||
:disabled="isAdmin"
|
||||
>
|
||||
{{ strings.allow }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="muted">{{ strings.noCategories }}</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
|
||||
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
|
||||
<template v-if="submitting" #icon>
|
||||
<NcLoadingIcon :size="20" />
|
||||
</template>
|
||||
{{ isEditing ? strings.update : strings.create }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -170,6 +175,9 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
|
||||
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
|
||||
import InformationIcon from '@icons/Information.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import type { Role, CategoryHeader } from '@/types'
|
||||
@@ -188,8 +196,11 @@ export default defineComponent({
|
||||
NcLoadingIcon,
|
||||
NcTextField,
|
||||
NcTextArea,
|
||||
PageHeader,
|
||||
ArrowLeftIcon,
|
||||
InformationIcon,
|
||||
PageWrapper,
|
||||
AppToolbar,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -429,8 +440,6 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-role-edit {
|
||||
max-width: 1200px;
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
@@ -453,10 +462,6 @@ export default defineComponent({
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-actions {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
@@ -1,104 +1,106 @@
|
||||
<template>
|
||||
<div class="admin-role-list">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div>
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<p class="muted">{{ strings.subtitle }}</p>
|
||||
</div>
|
||||
<NcButton @click="createRole" variant="primary">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createRole }}
|
||||
</NcButton>
|
||||
<PageWrapper>
|
||||
<template #toolbar>
|
||||
<AppToolbar>
|
||||
<template #right>
|
||||
<NcButton @click="createRole" variant="primary">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createRole }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</AppToolbar>
|
||||
</template>
|
||||
|
||||
<div class="admin-role-list">
|
||||
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Role list -->
|
||||
<div v-else-if="roles.length > 0" class="roles-content">
|
||||
<div class="roles-table">
|
||||
<div class="table-header">
|
||||
<div class="col-id">{{ strings.id }}</div>
|
||||
<div class="col-name">{{ strings.name }}</div>
|
||||
<div class="col-description">{{ strings.description }}</div>
|
||||
<div class="col-created">{{ strings.created }}</div>
|
||||
<div class="col-actions">{{ strings.actions }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="role in roles" :key="role.id" class="table-row">
|
||||
<div class="col-id">
|
||||
<span class="role-id">{{ role.id }}</span>
|
||||
<!-- Role list -->
|
||||
<div v-else-if="roles.length > 0" class="roles-content">
|
||||
<div class="roles-table">
|
||||
<div class="table-header">
|
||||
<div class="col-id">{{ strings.id }}</div>
|
||||
<div class="col-name">{{ strings.name }}</div>
|
||||
<div class="col-description">{{ strings.description }}</div>
|
||||
<div class="col-created">{{ strings.created }}</div>
|
||||
<div class="col-actions">{{ strings.actions }}</div>
|
||||
</div>
|
||||
|
||||
<div class="col-name">
|
||||
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
|
||||
</div>
|
||||
<div v-for="role in roles" :key="role.id" class="table-row">
|
||||
<div class="col-id">
|
||||
<span class="role-id">{{ role.id }}</span>
|
||||
</div>
|
||||
|
||||
<div class="col-description">
|
||||
<span v-if="role.description" class="role-description">{{ role.description }}</span>
|
||||
<span v-else class="muted">{{ strings.noDescription }}</span>
|
||||
</div>
|
||||
<div class="col-name">
|
||||
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="col-created">
|
||||
<NcDateTime :timestamp="role.createdAt * 1000" />
|
||||
</div>
|
||||
<div class="col-description">
|
||||
<span v-if="role.description" class="role-description">{{ role.description }}</span>
|
||||
<span v-else class="muted">{{ strings.noDescription }}</span>
|
||||
</div>
|
||||
|
||||
<div class="col-actions">
|
||||
<NcActions>
|
||||
<NcActionButton @click="editRole(role.id)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcActionButton>
|
||||
<NcActionButton :disabled="isSystemRole(role.id)" @click="confirmDelete(role)">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.delete }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
<div class="col-created">
|
||||
<NcDateTime :timestamp="role.createdAt * 1000" />
|
||||
</div>
|
||||
|
||||
<div class="col-actions">
|
||||
<NcActions>
|
||||
<NcActionButton @click="editRole(role.id)">
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.edit }}
|
||||
</NcActionButton>
|
||||
<NcActionButton :disabled="isSystemRole(role.id)" @click="confirmDelete(role)">
|
||||
<template #icon>
|
||||
<DeleteIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.delete }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="createRole">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createRole }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
>
|
||||
<template #action>
|
||||
<NcButton @click="createRole">
|
||||
<template #icon>
|
||||
<PlusIcon :size="20" />
|
||||
</template>
|
||||
{{ strings.createRole }}
|
||||
</NcButton>
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -112,6 +114,9 @@ import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import PencilIcon from '@icons/Pencil.vue'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppToolbar from '@/components/AppToolbar.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import type { Role } from '@/types'
|
||||
@@ -128,6 +133,9 @@ export default defineComponent({
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
DeleteIcon,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
AppToolbar,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,145 +1,148 @@
|
||||
<template>
|
||||
<div class="admin-user-list">
|
||||
<div class="page-header">
|
||||
<h2>{{ strings.title }}</h2>
|
||||
<p class="muted">{{ strings.subtitle }}</p>
|
||||
</div>
|
||||
<PageWrapper :full-width="true">
|
||||
<div class="admin-user-list">
|
||||
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<span class="muted ml-8">{{ strings.loading }}</span>
|
||||
</div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="center mt-16">
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- User list -->
|
||||
<div v-else-if="users.length > 0" class="users-content">
|
||||
<div class="users-table">
|
||||
<div class="table-header">
|
||||
<div class="col-user">{{ strings.user }}</div>
|
||||
<div class="col-posts">{{ strings.posts }}</div>
|
||||
<div class="col-roles">{{ strings.roles }}</div>
|
||||
<div class="col-joined">{{ strings.joined }}</div>
|
||||
<div class="col-status">{{ strings.status }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.userId"
|
||||
class="table-row"
|
||||
:class="{ 'is-deleted': user.isDeleted }"
|
||||
>
|
||||
<div class="col-user">
|
||||
<UserInfo :user-id="user.userId" :display-name="user.displayName" :avatar-size="40">
|
||||
<template #meta>
|
||||
<div class="user-id muted">@{{ user.userId }}</div>
|
||||
</template>
|
||||
</UserInfo>
|
||||
<!-- User list -->
|
||||
<div v-else-if="users.length > 0" class="users-content">
|
||||
<div class="users-table">
|
||||
<div class="table-header">
|
||||
<div class="col-user">{{ strings.user }}</div>
|
||||
<div class="col-posts">{{ strings.posts }}</div>
|
||||
<div class="col-roles">{{ strings.roles }}</div>
|
||||
<div class="col-joined">{{ strings.joined }}</div>
|
||||
<div class="col-status">{{ strings.status }}</div>
|
||||
</div>
|
||||
|
||||
<div class="col-posts">
|
||||
<div class="post-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ user.threadCount }}</span>
|
||||
<span class="stat-label muted">threads</span>
|
||||
</div>
|
||||
<div class="stat-divider">/</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ user.postCount }}</span>
|
||||
<span class="stat-label muted">posts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-roles">
|
||||
<div v-if="editingUserId === user.userId" class="roles-editor">
|
||||
<NcSelect
|
||||
v-model="editingRoles"
|
||||
:options="roleOptions"
|
||||
:placeholder="strings.selectRoles"
|
||||
:multiple="true"
|
||||
label="name"
|
||||
track-by="id"
|
||||
input-label="name"
|
||||
class="roles-select"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<NcButton @click="cancelEdit" :aria-label="strings.cancel" :title="strings.cancel">
|
||||
<template #icon>
|
||||
<CloseIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="primary"
|
||||
@click="saveRoles(user.userId)"
|
||||
:aria-label="strings.save"
|
||||
:title="strings.save"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="roles-display">
|
||||
<div class="roles-list">
|
||||
<span
|
||||
v-for="roleId in user.roles"
|
||||
:key="roleId"
|
||||
class="role-badge"
|
||||
:class="getRoleBadgeClass(roleId)"
|
||||
>
|
||||
{{ getRoleName(roleId) }}
|
||||
</span>
|
||||
<span v-if="user.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
|
||||
</div>
|
||||
<NcButton
|
||||
@click="startEdit(user.userId, user.roles)"
|
||||
:aria-label="strings.edit"
|
||||
:title="strings.edit"
|
||||
>
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.userId"
|
||||
class="table-row"
|
||||
:class="{ 'is-deleted': user.isDeleted }"
|
||||
>
|
||||
<div class="col-user">
|
||||
<UserInfo :user-id="user.userId" :display-name="user.displayName" :avatar-size="40">
|
||||
<template #meta>
|
||||
<div class="user-id muted">@{{ user.userId }}</div>
|
||||
</template>
|
||||
</NcButton>
|
||||
</UserInfo>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-joined">
|
||||
<NcDateTime :timestamp="user.createdAt * 1000" />
|
||||
</div>
|
||||
<div class="col-posts">
|
||||
<div class="post-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ user.threadCount }}</span>
|
||||
<span class="stat-label muted">threads</span>
|
||||
</div>
|
||||
<div class="stat-divider">/</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ user.postCount }}</span>
|
||||
<span class="stat-label muted">posts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-status">
|
||||
<span v-if="user.isDeleted" class="status-badge status-deleted">
|
||||
{{ strings.deleted }}
|
||||
</span>
|
||||
<span v-else class="status-badge status-active">
|
||||
{{ strings.active }}
|
||||
</span>
|
||||
<div class="col-roles">
|
||||
<div v-if="editingUserId === user.userId" class="roles-editor">
|
||||
<NcSelect
|
||||
v-model="editingRoles"
|
||||
:options="roleOptions"
|
||||
:placeholder="strings.selectRoles"
|
||||
:multiple="true"
|
||||
label="name"
|
||||
track-by="id"
|
||||
input-label="name"
|
||||
class="roles-select"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<NcButton
|
||||
@click="cancelEdit"
|
||||
:aria-label="strings.cancel"
|
||||
:title="strings.cancel"
|
||||
>
|
||||
<template #icon>
|
||||
<CloseIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcButton
|
||||
variant="primary"
|
||||
@click="saveRoles(user.userId)"
|
||||
:aria-label="strings.save"
|
||||
:title="strings.save"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="roles-display">
|
||||
<div class="roles-list">
|
||||
<span
|
||||
v-for="roleId in user.roles"
|
||||
:key="roleId"
|
||||
class="role-badge"
|
||||
:class="getRoleBadgeClass(roleId)"
|
||||
>
|
||||
{{ getRoleName(roleId) }}
|
||||
</span>
|
||||
<span v-if="user.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
|
||||
</div>
|
||||
<NcButton
|
||||
@click="startEdit(user.userId, user.roles)"
|
||||
:aria-label="strings.edit"
|
||||
:title="strings.edit"
|
||||
>
|
||||
<template #icon>
|
||||
<PencilIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-joined">
|
||||
<NcDateTime :timestamp="user.createdAt * 1000" />
|
||||
</div>
|
||||
|
||||
<div class="col-status">
|
||||
<span v-if="user.isDeleted" class="status-badge status-deleted">
|
||||
{{ strings.deleted }}
|
||||
</span>
|
||||
<span v-else class="status-badge status-active">
|
||||
{{ strings.active }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -153,6 +156,8 @@ import UserInfo from '@/components/UserInfo.vue'
|
||||
import PencilIcon from '@icons/Pencil.vue'
|
||||
import CheckIcon from '@icons/Check.vue'
|
||||
import CloseIcon from '@icons/Close.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import { ocs } from '@/axios'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import type { Role } from '@/types'
|
||||
@@ -183,6 +188,8 @@ export default defineComponent({
|
||||
NcDateTime,
|
||||
NcSelect,
|
||||
UserInfo,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
PencilIcon,
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.2.0
|
||||
0.4.0
|
||||
|
||||
Reference in New Issue
Block a user