mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: add role colors + improve user data structure/enrichment
This commit is contained in:
@@ -117,15 +117,19 @@ class AdminController extends OCSController {
|
||||
$statsByUserId[$stats->getUserId()] = $stats;
|
||||
}
|
||||
|
||||
// Get all Nextcloud users and enrich with forum data
|
||||
// Collect all user IDs first
|
||||
$userIds = [];
|
||||
$this->userManager->callForAllUsers(function ($user) use (&$userIds) {
|
||||
$userIds[] = $user->getUID();
|
||||
});
|
||||
|
||||
// Enrich all users at once for performance (includes roles)
|
||||
$enrichedUserData = $this->userService->enrichMultipleUsers($userIds);
|
||||
|
||||
// Build final user list with forum stats
|
||||
$enrichedUsers = [];
|
||||
$this->userManager->callForAllUsers(function ($user) use (&$enrichedUsers, $statsByUserId) {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Get user display name
|
||||
$userInfo = $this->userService->enrichUserData($userId);
|
||||
|
||||
// Get stats if they exist, otherwise use defaults
|
||||
foreach ($userIds as $userId) {
|
||||
$userInfo = $enrichedUserData[$userId];
|
||||
$stats = $statsByUserId[$userId] ?? null;
|
||||
|
||||
$userData = [
|
||||
@@ -137,20 +141,11 @@ class AdminController extends OCSController {
|
||||
'updatedAt' => $stats ? $stats->getUpdatedAt() : 0,
|
||||
'deletedAt' => $stats ? $stats->getDeletedAt() : null,
|
||||
'isDeleted' => $userInfo['isDeleted'],
|
||||
'roles' => [],
|
||||
'roles' => $userInfo['roles'],
|
||||
];
|
||||
|
||||
// Get user roles
|
||||
try {
|
||||
$userRoles = $this->userRoleMapper->findByUserId($userId);
|
||||
$userData['roles'] = array_map(fn ($ur) => $ur->getRoleId(), $userRoles);
|
||||
} catch (\Exception $e) {
|
||||
// User has no roles
|
||||
$userData['roles'] = [];
|
||||
}
|
||||
|
||||
$enrichedUsers[] = $userData;
|
||||
});
|
||||
}
|
||||
|
||||
return new DataResponse(['users' => $enrichedUsers]);
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -19,6 +19,7 @@ use OCA\Forum\Db\UserStatsMapper;
|
||||
use OCA\Forum\Service\BBCodeService;
|
||||
use OCA\Forum\Service\NotificationService;
|
||||
use OCA\Forum\Service\PermissionService;
|
||||
use OCA\Forum\Service\UserService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
@@ -43,6 +44,7 @@ class PostController extends OCSController {
|
||||
private PermissionService $permissionService,
|
||||
private ReadMarkerMapper $readMarkerMapper,
|
||||
private NotificationService $notificationService,
|
||||
private UserService $userService,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
@@ -86,10 +88,16 @@ class PostController extends OCSController {
|
||||
// Get current user ID to mark user's reactions
|
||||
$currentUserId = $this->userSession->getUser()?->getUID();
|
||||
|
||||
// Enrich posts with content and reactions
|
||||
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId) {
|
||||
// Extract unique author IDs
|
||||
$authorIds = array_unique(array_map(fn ($p) => $p->getAuthorId(), $posts));
|
||||
|
||||
// Batch fetch author data (includes roles)
|
||||
$authors = $this->userService->enrichMultipleUsers($authorIds);
|
||||
|
||||
// Enrich posts with content, reactions, and pre-fetched author data
|
||||
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors) {
|
||||
$postReactions = $reactionsByPostId[$p->getId()] ?? [];
|
||||
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId);
|
||||
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()]);
|
||||
}, $posts));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching posts by thread: ' . $e->getMessage());
|
||||
@@ -134,10 +142,13 @@ class PostController extends OCSController {
|
||||
// Get current user ID to mark user's reactions
|
||||
$currentUserId = $this->userSession->getUser()?->getUID();
|
||||
|
||||
// Enrich posts with content and reactions
|
||||
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId) {
|
||||
// For posts by a single author, we can optimize by fetching author data once
|
||||
$author = $this->userService->enrichUserData($authorId);
|
||||
|
||||
// Enrich posts with content, reactions, and pre-fetched author data
|
||||
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $author) {
|
||||
$postReactions = $reactionsByPostId[$p->getId()] ?? [];
|
||||
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId);
|
||||
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId, $author);
|
||||
}, $posts));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching posts by author: ' . $e->getMessage());
|
||||
|
||||
@@ -79,6 +79,8 @@ class RoleController extends OCSController {
|
||||
*
|
||||
* @param string $name Role name
|
||||
* @param string|null $description Role description
|
||||
* @param string|null $colorLight Light mode color
|
||||
* @param string|null $colorDark Dark mode color
|
||||
* @param bool $canAccessAdminTools Can access admin tools
|
||||
* @param bool $canEditRoles Can edit roles
|
||||
* @param bool $canEditCategories Can edit categories
|
||||
@@ -92,6 +94,8 @@ class RoleController extends OCSController {
|
||||
public function create(
|
||||
string $name,
|
||||
?string $description = null,
|
||||
?string $colorLight = null,
|
||||
?string $colorDark = null,
|
||||
bool $canAccessAdminTools = false,
|
||||
bool $canEditRoles = false,
|
||||
bool $canEditCategories = false,
|
||||
@@ -100,6 +104,8 @@ class RoleController extends OCSController {
|
||||
$role = new \OCA\Forum\Db\Role();
|
||||
$role->setName($name);
|
||||
$role->setDescription($description);
|
||||
$role->setColorLight($colorLight);
|
||||
$role->setColorDark($colorDark);
|
||||
$role->setCanAccessAdminTools($canAccessAdminTools);
|
||||
$role->setCanEditRoles($canEditRoles);
|
||||
$role->setCanEditCategories($canEditCategories);
|
||||
@@ -120,6 +126,8 @@ class RoleController extends OCSController {
|
||||
* @param int $id Role ID
|
||||
* @param string|null $name Role name
|
||||
* @param string|null $description Role description
|
||||
* @param string|null $colorLight Light mode color
|
||||
* @param string|null $colorDark Dark mode color
|
||||
* @param bool|null $canAccessAdminTools Can access admin tools
|
||||
* @param bool|null $canEditRoles Can edit roles
|
||||
* @param bool|null $canEditCategories Can edit categories
|
||||
@@ -134,6 +142,8 @@ class RoleController extends OCSController {
|
||||
int $id,
|
||||
?string $name = null,
|
||||
?string $description = null,
|
||||
?string $colorLight = null,
|
||||
?string $colorDark = null,
|
||||
?bool $canAccessAdminTools = null,
|
||||
?bool $canEditRoles = null,
|
||||
?bool $canEditCategories = null,
|
||||
@@ -147,6 +157,12 @@ class RoleController extends OCSController {
|
||||
if ($description !== null) {
|
||||
$role->setDescription($description);
|
||||
}
|
||||
if ($colorLight !== null) {
|
||||
$role->setColorLight($colorLight);
|
||||
}
|
||||
if ($colorDark !== null) {
|
||||
$role->setColorDark($colorDark);
|
||||
}
|
||||
if ($canAccessAdminTools !== null) {
|
||||
$role->setCanAccessAdminTools($canAccessAdminTools);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use OCA\Forum\Db\Post;
|
||||
use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Service\SearchService;
|
||||
use OCA\Forum\Service\UserService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
@@ -26,6 +27,7 @@ class SearchController extends OCSController {
|
||||
IRequest $request,
|
||||
private SearchService $searchService,
|
||||
private ThreadMapper $threadMapper,
|
||||
private UserService $userService,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
@@ -87,14 +89,27 @@ class SearchController extends OCSController {
|
||||
$offset
|
||||
);
|
||||
|
||||
// Enrich threads
|
||||
$enrichedThreads = array_map(function ($thread) {
|
||||
return Thread::enrichThread($thread);
|
||||
// Collect all unique author IDs from both threads and posts
|
||||
$allAuthorIds = [];
|
||||
foreach ($results['threads'] as $thread) {
|
||||
$allAuthorIds[] = $thread->getAuthorId();
|
||||
}
|
||||
foreach ($results['posts'] as $post) {
|
||||
$allAuthorIds[] = $post->getAuthorId();
|
||||
}
|
||||
$allAuthorIds = array_unique($allAuthorIds);
|
||||
|
||||
// Batch fetch all author data once
|
||||
$authors = $this->userService->enrichMultipleUsers($allAuthorIds);
|
||||
|
||||
// Enrich threads with pre-fetched author data
|
||||
$enrichedThreads = array_map(function ($thread) use ($authors) {
|
||||
return Thread::enrichThread($thread, $authors[$thread->getAuthorId()]);
|
||||
}, $results['threads']);
|
||||
|
||||
// Enrich posts (with thread context)
|
||||
$enrichedPosts = array_map(function ($post) {
|
||||
$enriched = Post::enrichPostContent($post);
|
||||
// Enrich posts with pre-fetched author data and thread context
|
||||
$enrichedPosts = array_map(function ($post) use ($authors) {
|
||||
$enriched = Post::enrichPostContent($post, [], [], null, $authors[$post->getAuthorId()]);
|
||||
// Add thread info for context
|
||||
try {
|
||||
$thread = $this->threadMapper->find($post->getThreadId());
|
||||
|
||||
@@ -16,6 +16,7 @@ use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Db\ThreadSubscriptionMapper;
|
||||
use OCA\Forum\Db\UserStatsMapper;
|
||||
use OCA\Forum\Service\UserPreferencesService;
|
||||
use OCA\Forum\Service\UserService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
@@ -36,6 +37,7 @@ class ThreadController extends OCSController {
|
||||
private UserStatsMapper $userStatsMapper,
|
||||
private ThreadSubscriptionMapper $threadSubscriptionMapper,
|
||||
private UserPreferencesService $userPreferencesService,
|
||||
private UserService $userService,
|
||||
private IUserSession $userSession,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
@@ -54,7 +56,17 @@ class ThreadController extends OCSController {
|
||||
public function index(): DataResponse {
|
||||
try {
|
||||
$threads = $this->threadMapper->findAll();
|
||||
return new DataResponse(array_map(fn ($t) => Thread::enrichThread($t), $threads));
|
||||
|
||||
// Extract unique author IDs
|
||||
$authorIds = array_unique(array_map(fn ($t) => $t->getAuthorId(), $threads));
|
||||
|
||||
// Batch fetch author data (includes roles)
|
||||
$authors = $this->userService->enrichMultipleUsers($authorIds);
|
||||
|
||||
// Enrich threads with pre-fetched author data
|
||||
return new DataResponse(array_map(function ($t) use ($authors) {
|
||||
return Thread::enrichThread($t, $authors[$t->getAuthorId()]);
|
||||
}, $threads));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching threads: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
@@ -77,7 +89,17 @@ class ThreadController extends OCSController {
|
||||
public function byCategory(int $categoryId, int $limit = 50, int $offset = 0): DataResponse {
|
||||
try {
|
||||
$threads = $this->threadMapper->findByCategoryId($categoryId, $limit, $offset);
|
||||
return new DataResponse(array_map(fn ($t) => Thread::enrichThread($t), $threads));
|
||||
|
||||
// Extract unique author IDs
|
||||
$authorIds = array_unique(array_map(fn ($t) => $t->getAuthorId(), $threads));
|
||||
|
||||
// Batch fetch author data (includes roles)
|
||||
$authors = $this->userService->enrichMultipleUsers($authorIds);
|
||||
|
||||
// Enrich threads with pre-fetched author data
|
||||
return new DataResponse(array_map(function ($t) use ($authors) {
|
||||
return Thread::enrichThread($t, $authors[$t->getAuthorId()]);
|
||||
}, $threads));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching threads by category: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
@@ -99,7 +121,14 @@ class ThreadController extends OCSController {
|
||||
public function byAuthor(string $authorId, int $limit = 50, int $offset = 0): DataResponse {
|
||||
try {
|
||||
$threads = $this->threadMapper->findByAuthorId($authorId, $limit, $offset);
|
||||
return new DataResponse(array_map(fn ($t) => Thread::enrichThread($t), $threads));
|
||||
|
||||
// For threads by a single author, we can optimize by fetching author data once
|
||||
$author = $this->userService->enrichUserData($authorId);
|
||||
|
||||
// Enrich threads with pre-fetched author data
|
||||
return new DataResponse(array_map(function ($t) use ($author) {
|
||||
return Thread::enrichThread($t, $author);
|
||||
}, $threads));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching threads by author: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
|
||||
@@ -82,6 +82,7 @@ class Post extends Entity implements JsonSerializable {
|
||||
array $bbcodes = [],
|
||||
array $reactions = [],
|
||||
?string $currentUserId = null,
|
||||
?array $author = null,
|
||||
): array {
|
||||
if (!is_array($post)) {
|
||||
$post = $post->jsonSerialize();
|
||||
@@ -95,11 +96,13 @@ class Post extends Entity implements JsonSerializable {
|
||||
}
|
||||
$post['content'] = $service->parse($post['content'], $bbcodes, $post['authorId'], $post['id']);
|
||||
|
||||
// Add author display name (obfuscated if user is deleted)
|
||||
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
|
||||
$userData = $userService->enrichUserData($post['authorId']);
|
||||
$post['authorDisplayName'] = $userData['displayName'];
|
||||
$post['authorIsDeleted'] = $userData['isDeleted'];
|
||||
// Add author object (includes display name, deleted status, and roles)
|
||||
if ($author === null) {
|
||||
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
|
||||
$post['author'] = $userService->enrichUserData($post['authorId']);
|
||||
} else {
|
||||
$post['author'] = $author;
|
||||
}
|
||||
|
||||
// Add reactions (grouped by emoji)
|
||||
$post['reactions'] = self::groupReactions($reactions, $currentUserId);
|
||||
|
||||
@@ -18,6 +18,10 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @method void setName(string $value)
|
||||
* @method string|null getDescription()
|
||||
* @method void setDescription(?string $value)
|
||||
* @method string|null getColorLight()
|
||||
* @method void setColorLight(?string $value)
|
||||
* @method string|null getColorDark()
|
||||
* @method void setColorDark(?string $value)
|
||||
* @method bool getCanAccessAdminTools()
|
||||
* @method void setCanAccessAdminTools(bool $value)
|
||||
* @method bool getCanEditRoles()
|
||||
@@ -30,6 +34,8 @@ use OCP\AppFramework\Db\Entity;
|
||||
class Role extends Entity implements JsonSerializable {
|
||||
protected $name;
|
||||
protected $description;
|
||||
protected $colorLight;
|
||||
protected $colorDark;
|
||||
protected $canAccessAdminTools;
|
||||
protected $canEditRoles;
|
||||
protected $canEditCategories;
|
||||
@@ -39,6 +45,8 @@ class Role extends Entity implements JsonSerializable {
|
||||
$this->addType('id', 'integer');
|
||||
$this->addType('name', 'string');
|
||||
$this->addType('description', 'string');
|
||||
$this->addType('colorLight', 'string');
|
||||
$this->addType('colorDark', 'string');
|
||||
$this->addType('canAccessAdminTools', 'boolean');
|
||||
$this->addType('canEditRoles', 'boolean');
|
||||
$this->addType('canEditCategories', 'boolean');
|
||||
@@ -50,6 +58,8 @@ class Role extends Entity implements JsonSerializable {
|
||||
'id' => $this->getId(),
|
||||
'name' => $this->getName(),
|
||||
'description' => $this->getDescription(),
|
||||
'colorLight' => $this->getColorLight(),
|
||||
'colorDark' => $this->getColorDark(),
|
||||
'canAccessAdminTools' => $this->getCanAccessAdminTools(),
|
||||
'canEditRoles' => $this->getCanEditRoles(),
|
||||
'canEditCategories' => $this->getCanEditCategories(),
|
||||
|
||||
@@ -74,6 +74,27 @@ class RoleMapper extends QBMapper {
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find multiple roles by IDs at once
|
||||
*
|
||||
* @param array<int> $ids
|
||||
* @return array<Role>
|
||||
*/
|
||||
public function findByIds(array $ids): array {
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Role>
|
||||
*/
|
||||
|
||||
@@ -91,16 +91,18 @@ class Thread extends Entity implements JsonSerializable {
|
||||
];
|
||||
}
|
||||
|
||||
public static function enrichThread(mixed $thread): array {
|
||||
public static function enrichThread(mixed $thread, ?array $author = null): array {
|
||||
if (!is_array($thread)) {
|
||||
$thread = $thread->jsonSerialize();
|
||||
}
|
||||
|
||||
// Add author display name (obfuscated if user is deleted)
|
||||
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
|
||||
$userData = $userService->enrichUserData($thread['authorId']);
|
||||
$thread['authorDisplayName'] = $userData['displayName'];
|
||||
$thread['authorIsDeleted'] = $userData['isDeleted'];
|
||||
// Add author object (includes display name, deleted status, and roles)
|
||||
if ($author === null) {
|
||||
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
|
||||
$thread['author'] = $userService->enrichUserData($thread['authorId']);
|
||||
} else {
|
||||
$thread['author'] = $author;
|
||||
}
|
||||
|
||||
// Add category information (slug and name) for navigation
|
||||
try {
|
||||
|
||||
@@ -69,6 +69,27 @@ class UserRoleMapper extends QBMapper {
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user roles for multiple users at once
|
||||
*
|
||||
* @param array<string> $userIds
|
||||
* @return array<UserRole>
|
||||
*/
|
||||
public function findByUserIds(array $userIds): array {
|
||||
if (empty($userIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->in('user_id', $qb->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<UserRole>
|
||||
*/
|
||||
|
||||
99
lib/Migration/Version4Date20251120210339.php
Normal file
99
lib/Migration/Version4Date20251120210339.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version4Date20251120210339 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return null|ISchemaWrapper
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
// Add color columns to forum_roles table
|
||||
if ($schema->hasTable('forum_roles')) {
|
||||
$table = $schema->getTable('forum_roles');
|
||||
|
||||
if (!$table->hasColumn('color_light')) {
|
||||
$table->addColumn('color_light', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 7,
|
||||
'default' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$table->hasColumn('color_dark')) {
|
||||
$table->addColumn('color_dark', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 7,
|
||||
'default' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
$this->updateDefaultRoleColors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update default roles with colors that are legible in both light and dark modes
|
||||
*/
|
||||
private function updateDefaultRoleColors(): void {
|
||||
$db = \OC::$server->get(\OCP\IDBConnection::class);
|
||||
|
||||
// Define colors for default roles
|
||||
// Light mode uses darker colors, dark mode uses lighter colors for better contrast
|
||||
$roleColors = [
|
||||
1 => [
|
||||
'light' => '#dc2626', // Red 600
|
||||
'dark' => '#f87171', // Red 400
|
||||
],
|
||||
2 => [
|
||||
'light' => '#2563eb', // Blue 600
|
||||
'dark' => '#60a5fa', // Blue 400
|
||||
],
|
||||
'User' => [
|
||||
'light' => '#059669', // Emerald 600
|
||||
'dark' => '#34d399', // Emerald 400
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($roleColors as $roleId => $colors) {
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->update('forum_roles')
|
||||
->set('color_light', $qb->createNamedParameter($colors['light']))
|
||||
->set('color_dark', $qb->createNamedParameter($colors['dark']))
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($roleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->executeStatement();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Service;
|
||||
|
||||
use OCA\Forum\Db\RoleMapper;
|
||||
use OCA\Forum\Db\UserRoleMapper;
|
||||
use OCA\Forum\Db\UserStatsMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\IUserManager;
|
||||
@@ -19,6 +21,8 @@ class UserService {
|
||||
public function __construct(
|
||||
private IUserManager $userManager,
|
||||
private UserStatsMapper $userStatsMapper,
|
||||
private RoleMapper $roleMapper,
|
||||
private UserRoleMapper $userRoleMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -58,18 +62,35 @@ class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich user data with display name and deleted status
|
||||
* Enrich user data with display name, deleted status, and roles
|
||||
*
|
||||
* @return array{userId: string, displayName: string, isDeleted: bool}
|
||||
* @param string $userId
|
||||
* @param array|null $roles Optional pre-fetched roles array
|
||||
* @return array{userId: string, displayName: string, isDeleted: bool, roles: array}
|
||||
*/
|
||||
public function enrichUserData(string $userId): array {
|
||||
public function enrichUserData(string $userId, ?array $roles = null): array {
|
||||
$isDeleted = $this->isUserDeleted($userId);
|
||||
$displayName = $this->getUserDisplayName($userId);
|
||||
|
||||
// If roles not provided, fetch them
|
||||
if ($roles === null) {
|
||||
$userRoles = $this->userRoleMapper->findByUserId($userId);
|
||||
$roles = [];
|
||||
foreach ($userRoles as $userRole) {
|
||||
try {
|
||||
$role = $this->roleMapper->find($userRole->getRoleId());
|
||||
$roles[] = $role->jsonSerialize();
|
||||
} catch (\Exception $e) {
|
||||
// Role not found, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'userId' => $userId,
|
||||
'displayName' => $displayName,
|
||||
'isDeleted' => $isDeleted,
|
||||
'roles' => $roles,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -77,13 +98,82 @@ class UserService {
|
||||
* Enrich multiple users at once (for performance)
|
||||
*
|
||||
* @param array<string> $userIds
|
||||
* @return array<string, array{userId: string, displayName: string, isDeleted: bool}>
|
||||
* @param array<string, array> $rolesMap Optional pre-fetched roles map (userId => roles[])
|
||||
* @return array<string, array{userId: string, displayName: string, isDeleted: bool, roles: array}>
|
||||
*/
|
||||
public function enrichMultipleUsers(array $userIds): array {
|
||||
public function enrichMultipleUsers(array $userIds, ?array $rolesMap = null): array {
|
||||
$result = [];
|
||||
|
||||
// If roles not provided, fetch them all at once
|
||||
if ($rolesMap === null) {
|
||||
$rolesMap = $this->fetchRolesForUsers($userIds);
|
||||
}
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$result[$userId] = $this->enrichUserData($userId);
|
||||
$isDeleted = $this->isUserDeleted($userId);
|
||||
$displayName = $this->getUserDisplayName($userId);
|
||||
|
||||
$result[$userId] = [
|
||||
'userId' => $userId,
|
||||
'displayName' => $displayName,
|
||||
'isDeleted' => $isDeleted,
|
||||
'roles' => $rolesMap[$userId] ?? [],
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch roles for multiple users efficiently
|
||||
*
|
||||
* @param array<string> $userIds
|
||||
* @return array<string, array> Map of userId => roles[]
|
||||
*/
|
||||
private function fetchRolesForUsers(array $userIds): array {
|
||||
if (empty($userIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rolesMap = [];
|
||||
|
||||
// Initialize all user IDs with empty arrays
|
||||
foreach ($userIds as $userId) {
|
||||
$rolesMap[$userId] = [];
|
||||
}
|
||||
|
||||
// Fetch all user roles for these users
|
||||
$userRoles = $this->userRoleMapper->findByUserIds($userIds);
|
||||
|
||||
// Group by user ID and fetch role details
|
||||
$roleIds = [];
|
||||
$userRolesByUser = [];
|
||||
foreach ($userRoles as $userRole) {
|
||||
$userId = $userRole->getUserId();
|
||||
$roleId = $userRole->getRoleId();
|
||||
|
||||
if (!isset($userRolesByUser[$userId])) {
|
||||
$userRolesByUser[$userId] = [];
|
||||
}
|
||||
$userRolesByUser[$userId][] = $roleId;
|
||||
$roleIds[$roleId] = true;
|
||||
}
|
||||
|
||||
// Fetch all roles at once
|
||||
$roles = [];
|
||||
$roleEntities = $this->roleMapper->findByIds(array_keys($roleIds));
|
||||
foreach ($roleEntities as $role) {
|
||||
$roles[$role->getId()] = $role->jsonSerialize();
|
||||
}
|
||||
|
||||
// Map roles to users
|
||||
foreach ($userRolesByUser as $userId => $userRoleIds) {
|
||||
foreach ($userRoleIds as $roleId) {
|
||||
if (isset($roles[$roleId])) {
|
||||
$rolesMap[$userId][] = $roles[$roleId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rolesMap;
|
||||
}
|
||||
}
|
||||
|
||||
24
openapi.json
24
openapi.json
@@ -5756,6 +5756,18 @@
|
||||
"default": null,
|
||||
"description": "Role description"
|
||||
},
|
||||
"colorLight": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Light mode color"
|
||||
},
|
||||
"colorDark": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Dark mode color"
|
||||
},
|
||||
"canAccessAdminTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
@@ -5987,6 +5999,18 @@
|
||||
"default": null,
|
||||
"description": "Role description"
|
||||
},
|
||||
"colorLight": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Light mode color"
|
||||
},
|
||||
"colorDark": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"default": null,
|
||||
"description": "Dark mode color"
|
||||
},
|
||||
"canAccessAdminTools": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<div class="author-info">
|
||||
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
|
||||
<UserInfo
|
||||
:user-id="post.authorId"
|
||||
:display-name="post.authorDisplayName || post.authorId"
|
||||
:is-deleted="post.authorIsDeleted"
|
||||
:user-id="post.author?.userId || post.authorId"
|
||||
:display-name="post.author?.displayName || post.authorId"
|
||||
:is-deleted="post.author?.isDeleted || false"
|
||||
:avatar-size="32"
|
||||
:roles="post.author?.roles || []"
|
||||
>
|
||||
<template #meta>
|
||||
<div class="post-meta">
|
||||
|
||||
120
src/components/RoleBadge.vue
Normal file
120
src/components/RoleBadge.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<span class="role-badge" :class="densityClass" :style="badgeStyle">
|
||||
{{ role.name }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import { isDarkTheme } from '@nextcloud/vue/functions/isDarkTheme'
|
||||
import type { Role } from '@/types'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'RoleBadge',
|
||||
props: {
|
||||
role: {
|
||||
type: Object as PropType<Role>,
|
||||
required: true,
|
||||
},
|
||||
density: {
|
||||
type: String as PropType<'normal' | 'compact'>,
|
||||
default: 'normal',
|
||||
validator: (value: string) => ['normal', 'compact'].includes(value),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
densityClass(): string {
|
||||
return `density-${this.density}`
|
||||
},
|
||||
|
||||
backgroundColor(): string {
|
||||
const isDark = isDarkTheme
|
||||
const color = isDark ? this.role.colorDark : this.role.colorLight
|
||||
|
||||
if (color) {
|
||||
return color
|
||||
}
|
||||
|
||||
// Fallback colors for system roles
|
||||
const fallbackColors: Record<number, { light: string; dark: string }> = {
|
||||
1: { light: '#dc2626', dark: '#f87171' }, // Admin - red
|
||||
2: { light: '#2563eb', dark: '#60a5fa' }, // Moderator - blue
|
||||
3: { light: '#059669', dark: '#34d399' }, // User - emerald
|
||||
}
|
||||
|
||||
const fallback = fallbackColors[this.role.id]
|
||||
if (fallback) {
|
||||
return isDark ? fallback.dark : fallback.light
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return isDark ? '#ffffff' : '#000000'
|
||||
},
|
||||
|
||||
textColor(): string {
|
||||
// Calculate luminance to determine if text should be black or white
|
||||
const color = this.backgroundColor
|
||||
const rgb = this.hexToRgb(color)
|
||||
|
||||
if (!rgb) {
|
||||
return '#ffffff'
|
||||
}
|
||||
|
||||
// Calculate relative luminance using WCAG formula
|
||||
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
|
||||
|
||||
// If luminance > 0.5, use dark text, otherwise use light text
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
},
|
||||
|
||||
badgeStyle(): Record<string, string> {
|
||||
return {
|
||||
backgroundColor: this.backgroundColor,
|
||||
color: this.textColor,
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
// Remove # if present
|
||||
hex = hex.replace(/^#/, '')
|
||||
|
||||
// Parse hex to RGB
|
||||
if (hex.length === 3) {
|
||||
// Convert shorthand (e.g., #fff) to full form
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const bigint = parseInt(hex, 16)
|
||||
return {
|
||||
r: (bigint >> 16) & 255,
|
||||
g: (bigint >> 8) & 255,
|
||||
b: bigint & 255,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
|
||||
&.density-compact {
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="result-meta">
|
||||
<span class="meta-item author">
|
||||
<AccountIcon :size="16" />
|
||||
{{ post.authorDisplayName || strings.deletedUser }}
|
||||
{{ post.author?.displayName || strings.deletedUser }}
|
||||
</span>
|
||||
<span class="meta-item time">
|
||||
<ClockIcon :size="16" />
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</span>
|
||||
<span class="meta-item author">
|
||||
<AccountIcon :size="16" />
|
||||
{{ thread.authorDisplayName || strings.deletedUser }}
|
||||
{{ thread.author?.displayName || strings.deletedUser }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<MessageIcon :size="16" />
|
||||
|
||||
@@ -19,10 +19,12 @@
|
||||
</div>
|
||||
<div class="thread-meta">
|
||||
<UserInfo
|
||||
:user-id="thread.authorId"
|
||||
:display-name="thread.authorDisplayName || thread.authorId"
|
||||
:is-deleted="thread.authorIsDeleted"
|
||||
:user-id="thread.author?.userId || thread.authorId"
|
||||
:display-name="thread.author?.displayName || thread.authorId"
|
||||
:is-deleted="thread.author?.isDeleted || false"
|
||||
:avatar-size="32"
|
||||
:roles="thread.author?.roles || []"
|
||||
:show-roles="false"
|
||||
layout="inline"
|
||||
@click.stop
|
||||
>
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<span v-else class="user-name deleted-user">
|
||||
{{ displayName || userId }}
|
||||
</span>
|
||||
<div v-if="showRoles && displayRoles.length > 0" class="role-badges">
|
||||
<RoleBadge v-for="role in displayRoles" :key="role.id" :role="role" density="compact" />
|
||||
</div>
|
||||
<template v-if="layout === 'inline'">
|
||||
<span class="meta-separator">·</span>
|
||||
<span class="meta-content">
|
||||
@@ -33,13 +36,16 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
import RoleBadge from './RoleBadge.vue'
|
||||
import type { Role } from '@/types'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'UserInfo',
|
||||
components: {
|
||||
UserAvatar,
|
||||
RoleBadge,
|
||||
},
|
||||
props: {
|
||||
userId: {
|
||||
@@ -67,11 +73,56 @@ export default defineComponent({
|
||||
default: 'column',
|
||||
validator: (value: string) => ['column', 'inline'].includes(value),
|
||||
},
|
||||
roles: {
|
||||
type: Array as PropType<Role[]>,
|
||||
default: () => [],
|
||||
},
|
||||
showRoles: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isClickable(): boolean {
|
||||
return this.clickable && !this.isDeleted
|
||||
},
|
||||
|
||||
displayRoles(): Role[] {
|
||||
if (!this.roles || this.roles.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Define default role IDs and their precedence
|
||||
const defaultRoleIds = [1, 2, 3] // Admin (1), Moderator (2), User (3)
|
||||
const rolePrecedence: Record<number, number> = {
|
||||
1: 1, // Admin - highest priority
|
||||
2: 2, // Moderator - medium priority
|
||||
3: 3, // User - lowest priority
|
||||
}
|
||||
|
||||
// Separate default and custom roles
|
||||
const defaultRoles = this.roles.filter((role) => defaultRoleIds.includes(role.id))
|
||||
const customRoles = this.roles.filter((role) => !defaultRoleIds.includes(role.id))
|
||||
|
||||
// Find the most prominent default role
|
||||
let primaryDefaultRole: Role | null = null
|
||||
if (defaultRoles.length > 0) {
|
||||
primaryDefaultRole = defaultRoles.reduce((mostProminent, currentRole) => {
|
||||
const currentPrecedence = rolePrecedence[currentRole.id] || 999
|
||||
const prominentPrecedence = rolePrecedence[mostProminent.id] || 999
|
||||
return currentPrecedence < prominentPrecedence ? currentRole : mostProminent
|
||||
})
|
||||
}
|
||||
|
||||
// Build the display list: primary default role + all custom roles
|
||||
const result: Role[] = []
|
||||
if (primaryDefaultRole) {
|
||||
result.push(primaryDefaultRole)
|
||||
}
|
||||
result.push(...customRoles)
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleNameClick(event: MouseEvent): void {
|
||||
@@ -111,6 +162,14 @@ export default defineComponent({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface CategoryHeader {
|
||||
categories?: Category[]
|
||||
}
|
||||
|
||||
export interface User {
|
||||
userId: string
|
||||
displayName: string
|
||||
isDeleted: boolean
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: number
|
||||
categoryId: number
|
||||
@@ -39,9 +46,8 @@ export interface Thread {
|
||||
isHidden: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
// Enriched fields (added by Thread::enrichThreadAuthor)
|
||||
authorDisplayName?: string
|
||||
authorIsDeleted?: boolean
|
||||
// Enriched fields
|
||||
author?: User
|
||||
categorySlug?: string | null
|
||||
categoryName?: string | null
|
||||
isSubscribed?: boolean
|
||||
@@ -59,9 +65,8 @@ export interface Post {
|
||||
editedAt: number | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
// Enriched fields (added by Post::enrichPostContent)
|
||||
authorDisplayName?: string
|
||||
authorIsDeleted?: boolean
|
||||
// Enriched fields
|
||||
author?: User
|
||||
// Thread context (added by SearchController for search results)
|
||||
threadTitle?: string
|
||||
threadSlug?: string
|
||||
@@ -109,6 +114,8 @@ export interface Role {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
colorLight: string | null
|
||||
colorDark: string | null
|
||||
canAccessAdminTools: boolean
|
||||
canEditRoles: boolean
|
||||
canEditCategories: boolean
|
||||
|
||||
@@ -114,8 +114,8 @@
|
||||
<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 class="meta-value" :class="{ 'deleted-user': thread.author?.isDeleted }">
|
||||
{{ thread.author?.displayName || thread.authorId }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="meta-divider">·</span>
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
v-model="formData.name"
|
||||
:label="strings.name"
|
||||
:placeholder="strings.namePlaceholder"
|
||||
:disabled="isSystemRole"
|
||||
:required="true"
|
||||
/>
|
||||
<p v-if="isSystemRole" class="help-text muted">
|
||||
@@ -67,6 +66,48 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Colors Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.colors }}</h3>
|
||||
<p class="muted">{{ strings.colorsDesc }}</p>
|
||||
|
||||
<div class="colors-grid">
|
||||
<div class="color-group">
|
||||
<label>{{ strings.colorLight }}</label>
|
||||
<div class="color-picker-row">
|
||||
<NcColorPicker v-model="formData.colorLight" @update:value="onLightColorChange">
|
||||
<NcButton>
|
||||
<template #icon>
|
||||
<div
|
||||
class="color-preview"
|
||||
:style="{ backgroundColor: formData.colorLight }"
|
||||
/>
|
||||
</template>
|
||||
{{ formData.colorLight || strings.colorLightPlaceholder }}
|
||||
</NcButton>
|
||||
</NcColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-group">
|
||||
<label>{{ strings.colorDark }}</label>
|
||||
<div class="color-picker-row">
|
||||
<NcColorPicker v-model="formData.colorDark" @update:value="onDarkColorChange">
|
||||
<NcButton>
|
||||
<template #icon>
|
||||
<div class="color-preview" :style="{ backgroundColor: formData.colorDark }" />
|
||||
</template>
|
||||
{{ formData.colorDark || strings.colorDarkPlaceholder }}
|
||||
</NcButton>
|
||||
</NcColorPicker>
|
||||
<NcButton @click="resetDarkColor">
|
||||
{{ strings.reset }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Role Permissions Section -->
|
||||
<section class="form-section">
|
||||
<h3>{{ strings.rolePermissions }}</h3>
|
||||
@@ -169,6 +210,7 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'
|
||||
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
@@ -192,6 +234,7 @@ export default defineComponent({
|
||||
components: {
|
||||
NcButton,
|
||||
NcCheckboxRadioSwitch,
|
||||
NcColorPicker,
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
NcTextField,
|
||||
@@ -211,10 +254,13 @@ export default defineComponent({
|
||||
formData: {
|
||||
name: '',
|
||||
description: '',
|
||||
colorLight: '#000000',
|
||||
colorDark: '#ffffff',
|
||||
canAccessAdminTools: false,
|
||||
canEditRoles: false,
|
||||
canEditCategories: false,
|
||||
},
|
||||
darkColorModified: false,
|
||||
permissions: {} as Record<number, CategoryPermission>,
|
||||
|
||||
strings: {
|
||||
@@ -231,6 +277,13 @@ export default defineComponent({
|
||||
namePlaceholder: t('forum', 'Enter role name'),
|
||||
descriptionPlaceholder: t('forum', 'Enter role description (optional)'),
|
||||
systemRoleNameWarning: t('forum', 'System role names cannot be changed'),
|
||||
colors: t('forum', 'Colors'),
|
||||
colorsDesc: t('forum', 'Set colors for this role badge'),
|
||||
colorLight: t('forum', 'Light Mode Color'),
|
||||
colorDark: t('forum', 'Dark Mode Color'),
|
||||
colorLightPlaceholder: t('forum', '#000000'),
|
||||
colorDarkPlaceholder: t('forum', '#ffffff'),
|
||||
reset: t('forum', 'Reset'),
|
||||
rolePermissions: t('forum', 'Role Permissions'),
|
||||
rolePermissionsDesc: t('forum', 'Set global permissions for this role'),
|
||||
canAccessAdminTools: t('forum', 'Can Access Admin Tools'),
|
||||
@@ -339,10 +392,17 @@ export default defineComponent({
|
||||
|
||||
this.formData.name = role.name
|
||||
this.formData.description = role.description || ''
|
||||
this.formData.colorLight = role.colorLight || '#000000'
|
||||
this.formData.colorDark = role.colorDark || '#ffffff'
|
||||
this.formData.canAccessAdminTools = role.canAccessAdminTools || false
|
||||
this.formData.canEditRoles = role.canEditRoles || false
|
||||
this.formData.canEditCategories = role.canEditCategories || false
|
||||
|
||||
// If colors are different, mark dark as modified
|
||||
if (role.colorLight && role.colorDark && role.colorLight !== role.colorDark) {
|
||||
this.darkColorModified = true
|
||||
}
|
||||
|
||||
// Load role permissions
|
||||
const permsResponse = await ocs.get<
|
||||
Array<{
|
||||
@@ -389,6 +449,8 @@ export default defineComponent({
|
||||
const roleData = {
|
||||
name: this.formData.name.trim(),
|
||||
description: this.formData.description.trim() || null,
|
||||
colorLight: this.formData.colorLight || null,
|
||||
colorDark: this.formData.colorDark || null,
|
||||
canAccessAdminTools: this.formData.canAccessAdminTools,
|
||||
canEditRoles: this.formData.canEditRoles,
|
||||
canEditCategories: this.formData.canEditCategories,
|
||||
@@ -434,6 +496,24 @@ export default defineComponent({
|
||||
goBack(): void {
|
||||
this.$router.push('/admin/roles')
|
||||
},
|
||||
|
||||
onLightColorChange(): void {
|
||||
// If dark color hasn't been manually modified, update it too
|
||||
if (!this.darkColorModified) {
|
||||
this.formData.colorDark = this.formData.colorLight
|
||||
}
|
||||
},
|
||||
|
||||
onDarkColorChange(): void {
|
||||
// Mark dark color as manually modified
|
||||
this.darkColorModified = true
|
||||
},
|
||||
|
||||
resetDarkColor(): void {
|
||||
// Reset dark color to match light color
|
||||
this.formData.colorDark = this.formData.colorLight
|
||||
this.darkColorModified = false
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -509,6 +589,39 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
.colors-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin-top: 12px;
|
||||
|
||||
.color-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 0 1 auto;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: var(--color-main-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.color-picker-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.permissions-checkboxes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
<span class="role-name" :class="getRoleClass(row.id)">{{ row.name }}</span>
|
||||
<RoleBadge :role="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
@@ -107,6 +107,7 @@ import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import AdminTable, { type TableColumn } from '@/components/AdminTable.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import PencilIcon from '@icons/Pencil.vue'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
@@ -127,6 +128,7 @@ export default defineComponent({
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
AdminTable,
|
||||
RoleBadge,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
DeleteIcon,
|
||||
@@ -201,15 +203,6 @@ export default defineComponent({
|
||||
return roleId <= 3
|
||||
},
|
||||
|
||||
getRoleClass(roleId: number): string {
|
||||
const roleClasses: Record<number, string> = {
|
||||
1: 'role-admin',
|
||||
2: 'role-moderator',
|
||||
3: 'role-member',
|
||||
}
|
||||
return roleClasses[roleId] || ''
|
||||
},
|
||||
|
||||
createRole(): void {
|
||||
this.$router.push('/admin/roles/create')
|
||||
},
|
||||
@@ -271,24 +264,6 @@ export default defineComponent({
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
:deep(.role-name) {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--color-main-text);
|
||||
|
||||
&.role-admin {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&.role-moderator {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
&.role-member {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.role-description) {
|
||||
color: var(--color-text-lighter);
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -55,14 +55,7 @@
|
||||
|
||||
<template #cell-roles="{ row }">
|
||||
<div class="roles-list">
|
||||
<span
|
||||
v-for="roleId in row.roles"
|
||||
:key="roleId"
|
||||
class="role-badge"
|
||||
:class="getRoleBadgeClass(roleId)"
|
||||
>
|
||||
{{ getRoleName(roleId) }}
|
||||
</span>
|
||||
<RoleBadge v-for="role in row.roles" :key="role.id" :role="role" density="compact" />
|
||||
<span v-if="row.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -141,6 +134,7 @@ import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcDialog from '@nextcloud/vue/components/NcDialog'
|
||||
import UserInfo from '@/components/UserInfo.vue'
|
||||
import RoleBadge from '@/components/RoleBadge.vue'
|
||||
import AdminTable, { type TableColumn } from '@/components/AdminTable.vue'
|
||||
import PencilIcon from '@icons/Pencil.vue'
|
||||
import PageWrapper from '@/components/PageWrapper.vue'
|
||||
@@ -158,7 +152,7 @@ interface AdminUser {
|
||||
updatedAt: number
|
||||
deletedAt: number | null
|
||||
isDeleted: boolean
|
||||
roles: number[]
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
@@ -178,6 +172,7 @@ export default defineComponent({
|
||||
NcSelect,
|
||||
NcDialog,
|
||||
UserInfo,
|
||||
RoleBadge,
|
||||
AdminTable,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
@@ -260,27 +255,14 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
|
||||
getRoleName(roleId: number): string {
|
||||
const role = this.allRoles.find((r) => r.id === roleId)
|
||||
return role?.name || t('forum', 'Unknown Role')
|
||||
},
|
||||
|
||||
getRoleBadgeClass(roleId: number): string {
|
||||
const roleClasses: Record<number, string> = {
|
||||
1: 'role-admin',
|
||||
2: 'role-moderator',
|
||||
3: 'role-member',
|
||||
}
|
||||
return roleClasses[roleId] || 'role-unknown'
|
||||
},
|
||||
|
||||
startEdit(userId: string, currentRoles: number[]): void {
|
||||
startEdit(userId: string, currentRoles: Role[]): void {
|
||||
this.editingUserId = userId
|
||||
this.originalRoles = [...currentRoles]
|
||||
this.originalRoles = currentRoles.map((r) => r.id)
|
||||
|
||||
// Convert role IDs to role options for NcSelectTags
|
||||
// Convert roles to role options for NcSelectTags
|
||||
// IMPORTANT: Must use the same object references from roleOptions
|
||||
this.editingRoles = this.roleOptions.filter((option) => currentRoles.includes(option.id))
|
||||
const currentRoleIds = currentRoles.map((r) => r.id)
|
||||
this.editingRoles = this.roleOptions.filter((option) => currentRoleIds.includes(option.id))
|
||||
},
|
||||
|
||||
cancelEdit(): void {
|
||||
@@ -322,7 +304,7 @@ export default defineComponent({
|
||||
// Update local user data
|
||||
const user = this.users.find((u) => u.userId === userId)
|
||||
if (user) {
|
||||
user.roles = newRoleIds
|
||||
user.roles = this.allRoles.filter((r) => newRoleIds.includes(r.id))
|
||||
}
|
||||
|
||||
this.cancelEdit()
|
||||
@@ -400,34 +382,6 @@ export default defineComponent({
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.role-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.role-admin {
|
||||
background: var(--color-error-light);
|
||||
color: var(--color-error-dark);
|
||||
}
|
||||
|
||||
&.role-moderator {
|
||||
background: var(--color-warning-light);
|
||||
color: var(--color-warning-dark);
|
||||
}
|
||||
|
||||
&.role-member {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
&.role-unknown {
|
||||
background: var(--color-background-dark);
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
|
||||
Reference in New Issue
Block a user