feat: add role colors + improve user data structure/enrichment

This commit is contained in:
2025-11-21 00:51:35 +02:00
parent 90459368b1
commit 88cb7f5aa9
24 changed files with 720 additions and 153 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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