Compare commits

...

21 Commits

Author SHA1 Message Date
5391d8fffe chore(master): release 0.4.0 2025-11-19 09:32:37 +02:00
b0bfbbccdf feat(BBCodeEditor): add attachment disclaimer 2025-11-19 02:54:07 +02:00
9525ebfb97 fix(ThreadCard): mobile responsiveness 2025-11-19 02:54:07 +02:00
67e9fb9f8c fix(ProfileView): mobile responsiveness 2025-11-19 02:54:06 +02:00
a36da9f882 feat(AppNavigation): save collapse state to local storage 2025-11-19 02:54:06 +02:00
c0762158d7 fix: mobile responsiveness 2025-11-19 02:54:06 +02:00
479cdbbba5 refactor: clean up AppNavigation active logic 2025-11-19 02:54:05 +02:00
255a5cf53d feat(BBCodeToolbar): add emoji picker button 2025-11-19 02:54:05 +02:00
feeefa2926 feat(PostReactions): use Nextcloud emoji picker 2025-11-19 02:54:03 +02:00
f49561ccca chore(master): release 0.3.0 2025-11-18 10:31:32 +02:00
e59a6f4dc7 feat: add skeleton component + update categories header ui 2025-11-18 10:26:51 +02:00
9719f518e2 feat: load forum title/subtitle from public endpoint 2025-11-18 10:26:50 +02:00
2d10b461c0 feat: add page header component 2025-11-18 10:26:50 +02:00
2264289b56 refactor: move AppToolbar position to PageWrapper slot 2025-11-18 02:44:59 +02:00
3ef545dcc9 refactor: add PageWrapper component 2025-11-18 02:21:08 +02:00
fb905f8d15 docs: add release to README.md 2025-11-18 02:13:19 +02:00
278f1b3cc4 feat: user preferences page & auto thread subs pref 2025-11-18 01:38:57 +02:00
5ee8a16aa1 fix: user stats post is_first_post counts 2025-11-17 18:22:41 +02:00
a1671baf2d chore(master): release 0.2.1 2025-11-17 18:15:40 +02:00
71ee133ac6 fix: unread counts for deleted posts 2025-11-17 18:13:19 +02:00
1add8db287 fix: thread card hover styles 2025-11-17 17:52:34 +02:00
42 changed files with 3834 additions and 3098 deletions

View File

@@ -1 +1 @@
{".":"0.2.0"}
{".":"0.4.0"}

View File

@@ -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)

View File

@@ -5,6 +5,8 @@ SPDX-License-Identifier: CC0-1.0
# Nextcloud Forum
![GitHub Release](https://img.shields.io/github/v/release/chenasraf/nextcloud-forum)
A full-featured forum application for Nextcloud, allowing users to create discussion categories,
threads, and posts within their Nextcloud instance.

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);

View 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);
}
}
}

View File

@@ -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,

View 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);
}
}
}

View File

@@ -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
*

View 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;
}
}

View File

@@ -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();

View File

@@ -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",

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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);
}
}
}

View 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>

View File

@@ -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>

View File

@@ -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') },
],
},
]

View File

@@ -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') },

View File

@@ -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>

View File

@@ -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
}
},

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;

View 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>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -1 +1 @@
0.2.0
0.4.0