Compare commits

...

15 Commits

32 changed files with 1088 additions and 523 deletions

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ tsconfig.app.tsbuildinfo
.env.keys
.envrc
tests/.phpunit.result.cache
stats.html

View File

@@ -1 +1 @@
{".":"0.5.0"}
{".":"0.7.0"}

View File

@@ -1,5 +1,32 @@
# Changelog
## [0.7.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.6.0...v0.7.0) (2025-11-20)
### Features
* add forum:set-role occ command ([96a4252](https://github.com/chenasraf/nextcloud-forum/commit/96a42525d342ca0e791ea20b224838fc395f906c))
* weekly task now calculates category/thread post counts ([84edf8e](https://github.com/chenasraf/nextcloud-forum/commit/84edf8ecbe3512d948960948299d378fae4b2c91))
### Bug Fixes
* build excluded files ([d6d4694](https://github.com/chenasraf/nextcloud-forum/commit/d6d4694ce0cc64c0c220bb834bcec15ec107e343))
## [0.6.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.5.0...v0.6.0) (2025-11-20)
### Features
* **AdminCategoryEdit:** pre-populate role dropdowns with default roles ([c8ca4f9](https://github.com/chenasraf/nextcloud-forum/commit/c8ca4f9168d597d1f6281a9fab794052b6f9a33b))
* **AdminTable:** improve users/role tables design ([432c31f](https://github.com/chenasraf/nextcloud-forum/commit/432c31f6e2c71c1b18216f59a31e69d34223baff))
### Bug Fixes
* **AdminCategoryList:** list spacing ([22f9b78](https://github.com/chenasraf/nextcloud-forum/commit/22f9b78b1be0b9787a5d8ea0bf648630d086c7f6))
* **AdminUserList:** empty state display condition ([c16e804](https://github.com/chenasraf/nextcloud-forum/commit/c16e804d16480936f9f38903989e90aeecc4cd5b))
## [0.5.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.4.0...v0.5.0) (2025-11-19)

View File

@@ -188,7 +188,8 @@ appstore:
--exclude="bower.json" \
--exclude="karma.*" \
--exclude="protractor\.*" \
--exclude=".*" \
--exclude="/gen" \
--exclude="/.*" \
--exclude="dist/js/.*" \
--exclude="/src" \
--exclude="rename-template.sh" \

View File

@@ -36,7 +36,7 @@ This app is in early stages of development. While functional, you may encounter
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
]]></description>
<version>0.5.0</version>
<version>0.7.0</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>
@@ -56,11 +56,12 @@ The forum integrates seamlessly with your Nextcloud instance, using your existin
<nextcloud min-version="29" max-version="33"/>
</dependencies>
<background-jobs>
<job>OCA\Forum\Cron\RebuildUserStatsTask</job>
<job>OCA\Forum\Cron\RebuildStatsTask</job>
</background-jobs>
<commands>
<command>OCA\Forum\Command\TestNotifier</command>
<command>OCA\Forum\Command\RebuildUserStats</command>
<command>OCA\Forum\Command\SetRole</command>
</commands>
<navigations>
<navigation role="all">

View File

@@ -24,7 +24,7 @@ class {{pascalCase name}} extends Command {
*/
protected function configure(): void {
parent::configure();
$this->setName('jukebox:{{kebabCase name}}');
$this->setName('forum:{{kebabCase name}}');
}
/**

View File

@@ -1,22 +1,19 @@
<template>
<div>{{ startCase name }}</div>
</template>
<script>
// import NcComponentExample from '@nextcloud/vue/dist/Components/NcComponentExample.js'
<script lang="ts">
import { defineComopnent, type PropType } from 'vue'
// import NcComponentExample from '@nextcloud/vue/components/NcComponentExample'
//
// import IconExample from 'vue-material-design-icons/Example.vue'
// import IconExample from '@icons/Example.vue'
export default {
export default defineComponent({
name: '{{pascalCase name}}',
components: {
//
},
}
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,24 +1,23 @@
<template>
<div class="jukebox-{{ kebabCase name }}">{{ startCase name }} Page</div>
<div class="forum-{{ kebabCase name }}">{{ startCase name }} Page</div>
</template>
<script>
<script lang="ts">
import { defineComopnent, type PropType } from 'vue'
// import NcComponentExample from '@nextcloud/vue/components/NcComponentExample'
//
// import IconExample from 'vue-material-design-icons/Example.vue'
// import IconExample from '@icons/Example.vue'
export default {
export default defineComponent({
name: '{{pascalCase name}}Page',
components: {
//
},
}
})
</script>
<style scoped lang="scss">
/*
#jukebox-{{ kebabCase name }} {
#forum-{{ kebabCase name }} {
/* Your styles here */
}
*/
</style>

View File

@@ -42,6 +42,33 @@ class Application extends App implements IBootstrap {
public function boot(IBootContext $context): void {
}
/**
* Helper to parse Vite Manifest
*/
public static function getViteEntryScript(string $entryName): string {
$jsDir = realpath(__DIR__ . '/../' . Application::JS_DIR);
$manifestPath = dirname($jsDir) . '/.vite/manifest.json';
if (!file_exists($manifestPath)) {
return '';
}
$manifest = json_decode(file_get_contents($manifestPath), true);
if (isset($manifest[$entryName]['file'])) {
$manifestFile = $manifest[$entryName]['file'];
$fullPath = dirname($jsDir) . '/' . $manifestFile;
if (!file_exists($fullPath)) {
return '';
}
return pathinfo($manifestFile, PATHINFO_FILENAME);
}
return '';
}
public static function tableName(string $name): string {
// return self::APP_ID . '_' . $name;
return $name;

View File

@@ -7,14 +7,14 @@ declare(strict_types=1);
namespace OCA\Forum\Command;
use OCA\Forum\Service\UserStatsService;
use OCA\Forum\Service\StatsService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RebuildUserStats extends Command {
public function __construct(
private UserStatsService $userStatsService,
private StatsService $statsService,
) {
parent::__construct();
}
@@ -28,7 +28,7 @@ class RebuildUserStats extends Command {
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('<info>Rebuilding user statistics for all users...</info>');
$result = $this->userStatsService->createStatsForAllUsers();
$result = $this->statsService->rebuildAllUserStats();
$output->writeln(sprintf('Processed %d users', $result['users']));
$output->writeln(sprintf('Created %d new user stats', $result['created']));

91
lib/Command/SetRole.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Command;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\UserRole;
use OCA\Forum\Db\UserRoleMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SetRole extends Command {
public function __construct(
private RoleMapper $roleMapper,
private UserRoleMapper $userRoleMapper,
private IUserManager $userManager,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('forum:set-role')
->setDescription('Assign a forum role to a user')
->addArgument('username', InputArgument::REQUIRED, 'The username of the user')
->addArgument('role', InputArgument::REQUIRED, 'The role ID (numeric) or role name (case-insensitive) to assign');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$username = $input->getArgument('username');
$roleIdentifier = $input->getArgument('role');
// Check if user exists
$user = $this->userManager->get($username);
if ($user === null) {
$output->writeln("<error>User '$username' does not exist.</error>");
return 1;
}
// Find role by ID (if numeric) or by name (case insensitive)
$role = null;
if (is_numeric($roleIdentifier)) {
// Try to find by ID
try {
$role = $this->roleMapper->find((int)$roleIdentifier);
} catch (DoesNotExistException $e) {
$output->writeln("<error>Role with ID '$roleIdentifier' does not exist.</error>");
return 1;
}
} else {
// Try to find by name (case insensitive)
try {
$role = $this->roleMapper->findByNameCaseInsensitive($roleIdentifier);
} catch (MultipleObjectsReturnedException $e) {
$output->writeln("<error>Multiple roles found with name '$roleIdentifier'. Please use the role ID instead.</error>");
return 1;
} catch (DoesNotExistException $e) {
$output->writeln("<error>Role '$roleIdentifier' does not exist.</error>");
return 1;
}
}
// Check if user already has this role
$userRoles = $this->userRoleMapper->findByUserId($username);
foreach ($userRoles as $userRole) {
if ($userRole->getRoleId() === $role->getId()) {
$output->writeln("<comment>User '$username' already has the role '{$role->getName()}'.</comment>");
return 0;
}
}
// Add the role to the user
$userRole = new UserRole();
$userRole->setUserId($username);
$userRole->setRoleId($role->getId());
$userRole->setCreatedAt(time());
$this->userRoleMapper->insert($userRole);
$output->writeln("<info>Successfully assigned role '{$role->getName()}' to user '$username'.</info>");
return 0;
}
}

View File

@@ -18,7 +18,6 @@ class PageController extends Controller {
IRequest $request,
private LoggerInterface $logger,
) {
$this->logger->info('Forum page controller loaded');
parent::__construct($appName, $request);
}
@@ -32,9 +31,10 @@ class PageController extends Controller {
#[NoAdminRequired]
#[NoCSRFRequired]
public function index(): TemplateResponse {
$this->logger->info('Forum main page loaded');
$mainScript = Application::getViteEntryScript('app.ts');
return new TemplateResponse(Application::APP_ID, 'app', [
'script' => 'app',
'script' => Application::getViteEntryScript('app.ts'),
'style' => Application::getViteEntryScript('style.css'),
]);
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Cron;
use OCA\Forum\Service\StatsService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
class RebuildStatsTask extends TimedJob {
public function __construct(
ITimeFactory $time,
private StatsService $statsService,
private LoggerInterface $logger,
) {
parent::__construct($time);
// Run once a week (604800 seconds = 7 days)
$this->setInterval(604800);
}
protected function run($arguments): void {
$this->logger->info('Starting weekly stats rebuild');
// Rebuild user stats
$userResult = $this->statsService->rebuildAllUserStats();
$this->logger->info('User stats rebuild completed', [
'users' => $userResult['users'],
'created' => $userResult['created'],
'updated' => $userResult['updated'],
]);
// Rebuild category stats
$categoryResult = $this->statsService->rebuildAllCategoryStats();
$this->logger->info('Category stats rebuild completed', [
'categories' => $categoryResult['categories'],
'updated' => $categoryResult['updated'],
]);
// Rebuild thread stats
$threadResult = $this->statsService->rebuildAllThreadStats();
$this->logger->info('Thread stats rebuild completed', [
'threads' => $threadResult['threads'],
'updated' => $threadResult['updated'],
]);
$this->logger->info('Weekly stats rebuild completed');
}
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Cron;
use OCA\Forum\Service\UserStatsService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
class RebuildUserStatsTask extends TimedJob {
public function __construct(
ITimeFactory $time,
private UserStatsService $userStatsService,
private LoggerInterface $logger,
) {
parent::__construct($time);
// Run once a week (604800 seconds = 7 days)
$this->setInterval(604800);
}
protected function run($arguments): void {
$this->logger->info('Starting weekly user stats rebuild for all users');
$result = $this->userStatsService->createStatsForAllUsers();
$this->logger->info('User stats rebuild completed', [
'users' => $result['users'],
'created' => $result['created'],
'updated' => $result['updated'],
]);
}
}

View File

@@ -55,6 +55,25 @@ class RoleMapper extends QBMapper {
return $this->findEntity($qb);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function findByNameCaseInsensitive(string $name): Role {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq(
$qb->func()->lower('name'),
$qb->func()->lower($qb->createNamedParameter($name, IQueryBuilder::PARAM_STR))
)
);
return $this->findEntity($qb);
}
/**
* @return array<Role>
*/

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
namespace OCA\Forum\Listener;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\UserStatsService;
use OCA\Forum\Service\StatsService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\User\Events\UserCreatedEvent;
@@ -21,7 +21,7 @@ use Psr\Log\LoggerInterface;
class UserEventListener implements IEventListener {
public function __construct(
private UserStatsMapper $userStatsMapper,
private UserStatsService $userStatsService,
private StatsService $statsService,
private LoggerInterface $logger,
) {
}
@@ -40,7 +40,7 @@ class UserEventListener implements IEventListener {
try {
// Create user stats with zero counts for new user
$this->userStatsService->rebuildUserStats($userId);
$this->statsService->rebuildUserStats($userId);
$this->logger->info("Created user stats for new Nextcloud user: {$userId}");
} catch (\Exception $ex) {
$this->logger->error("Failed to create user stats for new user: {$userId}", [

View File

@@ -8,14 +8,14 @@ declare(strict_types=1);
namespace OCA\Forum\Migration;
use Closure;
use OCA\Forum\Service\UserStatsService;
use OCA\Forum\Service\StatsService;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version2Date20251114222614 extends SimpleMigrationStep {
public function __construct(
private UserStatsService $userStatsService,
private StatsService $statsService,
) {
}
@@ -108,7 +108,7 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$output->info('Creating user statistics for all users...');
$result = $this->userStatsService->createStatsForAllUsers();
$result = $this->statsService->rebuildAllUserStats();
$output->info(sprintf('Processed %d users', $result['users']));
$output->info(sprintf('Created %d new user stats', $result['created']));

View File

@@ -11,7 +11,7 @@ use OCP\IDBConnection;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
class UserStatsService {
class StatsService {
public function __construct(
private IDBConnection $db,
private IUserManager $userManager,
@@ -24,7 +24,7 @@ class UserStatsService {
*
* @return array{users: int, updated: int, created: int} Statistics about the creation
*/
public function createStatsForAllUsers(): array {
public function rebuildAllUserStats(): array {
// Get all user IDs from Nextcloud
$users = [];
$this->userManager->callForAllUsers(function ($user) use (&$users) {
@@ -50,16 +50,6 @@ class UserStatsService {
];
}
/**
* Rebuild user statistics from actual post and thread counts
*
* @return array{users: int, updated: int, created: int} Statistics about the rebuild
*/
public function rebuildAllUserStats(): array {
// Delegate to createStatsForAllUsers which processes all Nextcloud users
return $this->createStatsForAllUsers();
}
/**
* Rebuild statistics for a single user
*
@@ -170,4 +160,128 @@ class UserStatsService {
}
}
}
/**
* Rebuild thread and post counts for all categories
*
* @return array{categories: int, updated: int} Statistics about the rebuild
*/
public function rebuildAllCategoryStats(): array {
// Get all category IDs
$qb = $this->db->getQueryBuilder();
$qb->select('id')
->from('forum_categories');
$result = $qb->executeQuery();
$categoryIds = [];
while ($row = $result->fetch()) {
$categoryIds[] = (int)$row['id'];
}
$result->closeCursor();
$updated = 0;
foreach ($categoryIds as $categoryId) {
$this->rebuildCategoryStats($categoryId);
$updated++;
}
return [
'categories' => count($categoryIds),
'updated' => $updated,
];
}
/**
* Rebuild statistics for a single category
*
* @param int $categoryId The category ID to rebuild stats for
* @return void
*/
public function rebuildCategoryStats(int $categoryId): void {
// Count non-deleted threads in this category
$threadQb = $this->db->getQueryBuilder();
$threadQb->select($threadQb->func()->count('*', 'count'))
->from('forum_threads')
->where($threadQb->expr()->eq('category_id', $threadQb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->andWhere($threadQb->expr()->isNull('deleted_at'));
$threadResult = $threadQb->executeQuery();
$threadCount = (int)($threadResult->fetchOne() ?? 0);
$threadResult->closeCursor();
// Count non-deleted posts in non-deleted threads in this category
$postQb = $this->db->getQueryBuilder();
$postQb->select($postQb->func()->count('*', 'count'))
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
->where($postQb->expr()->eq('t.category_id', $postQb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->andWhere($postQb->expr()->isNull('p.deleted_at'))
->andWhere($postQb->expr()->isNull('t.deleted_at'));
$postResult = $postQb->executeQuery();
$postCount = (int)($postResult->fetchOne() ?? 0);
$postResult->closeCursor();
// Update category stats
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_categories')
->set('thread_count', $updateQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter(time(), \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
$updateQb->executeStatement();
}
/**
* Rebuild post counts for all threads
*
* @return array{threads: int, updated: int} Statistics about the rebuild
*/
public function rebuildAllThreadStats(): array {
// Get all non-deleted thread IDs
$qb = $this->db->getQueryBuilder();
$qb->select('id')
->from('forum_threads')
->where($qb->expr()->isNull('deleted_at'));
$result = $qb->executeQuery();
$threadIds = [];
while ($row = $result->fetch()) {
$threadIds[] = (int)$row['id'];
}
$result->closeCursor();
$updated = 0;
foreach ($threadIds as $threadId) {
$this->rebuildThreadStats($threadId);
$updated++;
}
return [
'threads' => count($threadIds),
'updated' => $updated,
];
}
/**
* Rebuild statistics for a single thread
*
* @param int $threadId The thread ID to rebuild stats for
* @return void
*/
public function rebuildThreadStats(int $threadId): void {
// Count non-deleted posts in this thread
$postQb = $this->db->getQueryBuilder();
$postQb->select($postQb->func()->count('*', 'count'))
->from('forum_posts')
->where($postQb->expr()->eq('thread_id', $postQb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->andWhere($postQb->expr()->isNull('deleted_at'));
$postResult = $postQb->executeQuery();
$postCount = (int)($postResult->fetchOne() ?? 0);
$postResult->closeCursor();
// Update thread stats
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_threads')
->set('post_count', $updateQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter(time(), \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
$updateQb->executeStatement();
}
}

View File

@@ -42,6 +42,7 @@
"lint-staged": "^16.2.6",
"prettier": "^2.8.8",
"prettier-plugin-vue": "^1.1.6",
"rollup-plugin-visualizer": "^6.0.5",
"sass": "^1.94.0",
"sass-embedded": "^1.93.3",
"typescript": "5.9.2",

120
pnpm-lock.yaml generated
View File

@@ -72,6 +72,9 @@ importers:
prettier-plugin-vue:
specifier: ^1.1.6
version: 1.1.6
rollup-plugin-visualizer:
specifier: ^6.0.5
version: 6.0.5(rollup@4.53.2)
sass:
specifier: ^1.94.0
version: 1.94.0
@@ -1478,6 +1481,10 @@ packages:
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
engines: {node: '>=20'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
@@ -1647,6 +1654,10 @@ packages:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
define-lazy-prop@2.0.0:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
@@ -2109,6 +2120,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.4.0:
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
engines: {node: '>=18'}
@@ -2357,6 +2372,11 @@ packages:
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -2452,6 +2472,10 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -2837,6 +2861,10 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
open@8.4.2:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -3085,6 +3113,10 @@ packages:
remark-unlink-protocols@1.0.0:
resolution: {integrity: sha512-5j/F28jhFmxeyz8nuJYYIWdR4nNpKWZ8A+tVwnK/0pq7Rjue33CINEYSckSq2PZvedhKUwbn08qyiuGoPLBung==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -3155,6 +3187,19 @@ packages:
peerDependencies:
rollup: ^4.0.0
rollup-plugin-visualizer@6.0.5:
resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
rolldown: 1.x || ^1.0.0-beta
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
rollup@4.53.2:
resolution: {integrity: sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -3388,6 +3433,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -3906,6 +3955,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
@@ -3922,6 +3975,10 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -3933,6 +3990,14 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -5461,6 +5526,12 @@ snapshots:
slice-ansi: 7.1.2
string-width: 8.1.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clone@2.1.2: {}
color-convert@2.0.1:
@@ -5622,6 +5693,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
define-lazy-prop@2.0.0: {}
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
@@ -6214,6 +6287,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.4.0: {}
get-intrinsic@1.3.0:
@@ -6488,6 +6563,8 @@ snapshots:
is-decimal@2.0.1: {}
is-docker@2.2.1: {}
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -6577,6 +6654,10 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
isarray@1.0.0: {}
isarray@2.0.5: {}
@@ -7118,6 +7199,12 @@ snapshots:
dependencies:
mimic-function: 5.0.1
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -7415,6 +7502,8 @@ snapshots:
mdast-squeeze-paragraphs: 6.0.0
unist-util-visit: 5.0.0
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
requireindex@1.2.0: {}
@@ -7481,6 +7570,15 @@ snapshots:
dependencies:
rollup: 4.53.2
rollup-plugin-visualizer@6.0.5(rollup@4.53.2):
dependencies:
open: 8.4.2
picomatch: 4.0.3
source-map: 0.7.6
yargs: 17.7.2
optionalDependencies:
rollup: 4.53.2
rollup@4.53.2:
dependencies:
'@types/estree': 1.0.8
@@ -7738,6 +7836,8 @@ snapshots:
source-map@0.6.1: {}
source-map@0.7.6: {}
space-separated-tokens@2.0.2: {}
spdx-compare@1.0.0:
@@ -8376,6 +8476,12 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 6.2.3
@@ -8391,12 +8497,26 @@ snapshots:
xtend@4.0.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@4.0.0: {}
yaml@2.8.1: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
zwitch@2.0.4: {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 320 KiB

View File

@@ -0,0 +1,205 @@
<template>
<div class="admin-table">
<div class="table-scroll-container">
<div class="table-grid" :style="gridStyle">
<!-- Header Row -->
<div class="table-row header-row">
<div v-for="column in columns" :key="column.key" :class="`col-${column.key}`">
{{ column.label }}
</div>
<div v-if="hasActions" class="col-actions">{{ actionsLabel }}</div>
</div>
<!-- Data Rows -->
<div
v-for="row in rows"
:key="getRowKey(row)"
class="table-row data-row"
:class="getRowClass(row)"
>
<div v-for="column in columns" :key="column.key" :class="`col-${column.key}`">
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
{{ row[column.key] }}
</slot>
</div>
<div v-if="hasActions" class="col-actions">
<slot name="actions" :row="row" />
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
export interface TableColumn {
key: string
label: string
minWidth?: string
maxWidth?: string
width?: string
}
export default defineComponent({
name: 'AdminTable',
props: {
columns: {
type: Array as PropType<TableColumn[]>,
required: true,
},
rows: {
type: Array as PropType<any[]>,
required: true,
},
rowKey: {
type: String,
default: 'id',
},
hasActions: {
type: Boolean,
default: false,
},
actionsLabel: {
type: String,
default: 'Actions',
},
actionsWidth: {
type: String,
default: '98px',
},
rowClass: {
type: [String, Function] as PropType<
string | ((row: any) => string | Record<string, boolean>)
>,
default: '',
},
},
computed: {
gridStyle(): { gridTemplateColumns: string } {
const columnWidths = this.columns.map((col) => {
if (col.width) {
return col.width
}
const minWidth = col.minWidth || '120px'
const maxWidth = col.maxWidth || 'auto'
return `minmax(${minWidth}, ${maxWidth})`
})
if (this.hasActions) {
columnWidths.push(this.actionsWidth)
}
return {
gridTemplateColumns: columnWidths.join(' '),
}
},
totalColumns(): number {
return this.columns.length + (this.hasActions ? 1 : 0)
},
},
methods: {
getRowKey(row: any): string | number {
return row[this.rowKey]
},
getRowClass(row: any): string | Record<string, boolean> {
if (typeof this.rowClass === 'function') {
return this.rowClass(row)
}
return this.rowClass
},
},
})
</script>
<style scoped lang="scss">
.admin-table {
.table-scroll-container {
overflow-x: auto;
background: var(--color-border);
&::-webkit-scrollbar {
height: 8px;
}
&::-webkit-scrollbar-track {
background: var(--color-background-dark);
}
&::-webkit-scrollbar-thumb {
background: var(--color-text-maxcontrast);
border-radius: 4px;
&:hover {
background: var(--color-main-text);
}
}
}
.table-grid {
display: grid;
width: fit-content;
min-width: 100%;
.table-row {
display: contents;
>div {
padding: 16px;
background: var(--color-main-background);
display: flex;
align-items: center;
transition: background 0.15s ease;
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
&:last-child {
border-right: none;
}
}
}
.header-row>div {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-maxcontrast);
background: var(--color-background-hover);
transition: none;
}
.data-row {
&:hover>div {
background: var(--color-background-hover);
}
&:last-child>div {
border-bottom: none;
}
}
.col-actions {
justify-content: center;
position: sticky;
right: 0;
z-index: 1;
box-shadow: -8px 0 12px rgba(0, 0, 0, 0.08);
@media (min-width: 1025px) {
box-shadow: -4px 0 8px rgba(0, 0, 0, 0.05);
}
// Ensure background covers scrolled content
&::before {
content: '';
position: absolute;
inset: 0;
background: inherit;
z-index: -1;
}
}
}
}
</style>

View File

@@ -14,7 +14,7 @@
</template>
</NcButton>
<NcEmojiPicker @select="handleEmojiSelect">
<LazyEmojiPicker @select="handleEmojiSelect">
<NcButton
variant="tertiary"
:aria-label="strings.emojiLabel"
@@ -25,7 +25,7 @@
<EmoticonIcon :size="20" />
</template>
</NcButton>
</NcEmojiPicker>
</LazyEmojiPicker>
<div class="toolbar-spacer"></div>
@@ -49,7 +49,7 @@
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker'
import LazyEmojiPicker from '@/components/LazyEmojiPicker'
import { getFilePickerBuilder } from '@nextcloud/dialogs'
import FormatBoldIcon from '@icons/FormatBold.vue'
import FormatItalicIcon from '@icons/FormatItalic.vue'
@@ -91,7 +91,7 @@ export default defineComponent({
name: 'BBCodeToolbar',
components: {
NcButton,
NcEmojiPicker,
LazyEmojiPicker,
BBCodeHelpDialog,
EmoticonIcon,
HelpCircleIcon,

View File

@@ -0,0 +1,3 @@
import { defineAsyncComponent } from 'vue'
export default defineAsyncComponent(() => import('@nextcloud/vue/components/NcEmojiPicker'))

View File

@@ -14,11 +14,11 @@
</button>
<!-- Add custom reaction button -->
<NcEmojiPicker @select="handleSelectEmoji" style="display: inline-block">
<LazyEmojiPicker @select="handleSelectEmoji" style="display: inline-block">
<button class="add-reaction-button" :title="strings.addReaction">
<span class="icon">+</span>
</button>
</NcEmojiPicker>
</LazyEmojiPicker>
</div>
</template>
@@ -27,12 +27,12 @@ import { defineComponent, type PropType } from 'vue'
import { t, n } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
import { useReactions, type ReactionGroup } from '@/composables/useReactions'
import NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker'
import LazyEmojiPicker from '@/components/LazyEmojiPicker'
export default defineComponent({
name: 'PostReactions',
components: {
NcEmojiPicker,
LazyEmojiPicker,
},
props: {
postId: {

View File

@@ -341,6 +341,9 @@ export default defineComponent({
if (!this.isEditing && newVal !== oldVal && newVal !== this.toKebabCase(this.formData.name)) {
this.slugManuallyEdited = true
}
if (!newVal) {
this.slugManuallyEdited = false
}
},
},
created() {
@@ -373,6 +376,24 @@ export default defineComponent({
if (this.isEditing && this.categoryId) {
await this.loadCategory()
await this.loadPermissions()
} else {
// When creating a new category, prefill with default roles
// View: Member (role ID 3)
const memberRole = this.roles.find((r) => r.id === 3)
if (memberRole) {
this.selectedViewRoles = [{ id: memberRole.id, label: memberRole.name }]
}
// Moderate: Admin (ID 1) and Moderator (ID 2)
const adminRole = this.roles.find((r) => r.id === 1)
const moderatorRole = this.roles.find((r) => r.id === 2)
this.selectedModerateRoles = []
if (adminRole) {
this.selectedModerateRoles.push({ id: adminRole.id, label: adminRole.name })
}
if (moderatorRole) {
this.selectedModerateRoles.push({ id: moderatorRole.id, label: moderatorRole.name })
}
}
} catch (e) {
console.error('Failed to load category', e)

View File

@@ -792,7 +792,6 @@ export default defineComponent({
.categories-section {
display: flex;
flex-direction: column;
gap: 32px;
}
.header-row {
@@ -805,6 +804,11 @@ export default defineComponent({
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 12px;
margin-top: 32px;
&:first-child {
margin-top: 0;
}
&:hover {
background: var(--color-background-hover);

View File

@@ -1,5 +1,5 @@
<template>
<PageWrapper>
<PageWrapper :full-width="true">
<template #toolbar>
<AppToolbar>
<template #right>
@@ -35,53 +35,48 @@
</NcEmptyContent>
<!-- Role list -->
<div v-else-if="roles.length > 0" class="roles-content">
<div class="roles-table">
<div class="table-header">
<div class="col-id">{{ strings.id }}</div>
<div class="col-name">{{ strings.name }}</div>
<div class="col-description">{{ strings.description }}</div>
<div class="col-created">{{ strings.created }}</div>
<div class="col-actions">{{ strings.actions }}</div>
</div>
<AdminTable
v-else-if="roles.length > 0"
:columns="tableColumns"
:rows="roles"
row-key="id"
:has-actions="true"
:actions-label="strings.actions"
>
<template #cell-id="{ row }">
<span class="role-id">{{ row.id }}</span>
</template>
<div v-for="role in roles" :key="role.id" class="table-row">
<div class="col-id">
<span class="role-id">{{ role.id }}</span>
</div>
<template #cell-name="{ row }">
<span class="role-name" :class="getRoleClass(row.id)">{{ row.name }}</span>
</template>
<div class="col-name">
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
</div>
<template #cell-description="{ row }">
<span v-if="row.description" class="role-description">{{ row.description }}</span>
<span v-else class="muted">{{ strings.noDescription }}</span>
</template>
<div class="col-description">
<span v-if="role.description" class="role-description">{{ role.description }}</span>
<span v-else class="muted">{{ strings.noDescription }}</span>
</div>
<template #cell-created="{ row }">
<NcDateTime :timestamp="row.createdAt * 1000" />
</template>
<div class="col-created">
<NcDateTime :timestamp="role.createdAt * 1000" />
</div>
<div class="col-actions">
<NcActions>
<NcActionButton @click="editRole(role.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcActionButton>
<NcActionButton :disabled="isSystemRole(role.id)" @click="confirmDelete(role)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcActionButton>
</NcActions>
</div>
</div>
</div>
</div>
<template #actions="{ row }">
<NcActions variant="secondary">
<NcActionButton @click="editRole(row.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcActionButton>
<NcActionButton :disabled="isSystemRole(row.id)" @click="confirmDelete(row)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcActionButton>
</NcActions>
</template>
</AdminTable>
<!-- Empty state -->
<NcEmptyContent
@@ -111,6 +106,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
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 PlusIcon from '@icons/Plus.vue'
import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
@@ -130,6 +126,7 @@ export default defineComponent({
NcDateTime,
NcActions,
NcActionButton,
AdminTable,
PlusIcon,
PencilIcon,
DeleteIcon,
@@ -170,6 +167,16 @@ export default defineComponent({
},
}
},
computed: {
tableColumns(): TableColumn[] {
return [
{ key: 'id', label: this.strings.id, minWidth: '50px', maxWidth: '100px' },
{ key: 'name', label: this.strings.name, minWidth: '120px' },
{ key: 'description', label: this.strings.description, minWidth: '250px' },
{ key: 'created', label: this.strings.created, minWidth: '120px' },
]
},
},
created() {
this.refresh()
},
@@ -256,96 +263,35 @@ export default defineComponent({
justify-content: center;
}
.page-header {
margin-bottom: 24px;
// Custom cell content styling
:deep(.role-id) {
font-weight: 600;
font-family: monospace;
font-size: 0.9rem;
color: var(--color-text-maxcontrast);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
:deep(.role-name) {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
&.role-admin {
color: var(--color-error);
}
h2 {
margin: 0 0 6px 0;
&.role-moderator {
color: var(--color-warning);
}
&.role-member {
color: var(--color-primary);
}
}
.roles-content {
.roles-table {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border);
border-radius: 8px;
overflow: hidden;
.table-header,
.table-row {
display: grid;
grid-template-columns: 60px 200px 1fr 150px 80px;
gap: 16px;
padding: 16px;
background: var(--color-main-background);
align-items: center;
}
.table-header {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-maxcontrast);
background: var(--color-background-hover);
}
.table-row {
&:hover {
background: var(--color-background-hover);
}
.col-id {
.role-id {
font-weight: 600;
font-family: monospace;
font-size: 0.9rem;
color: var(--color-text-maxcontrast);
}
}
.col-name {
.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);
}
}
}
.col-description {
.role-description {
color: var(--color-text-lighter);
font-size: 0.9rem;
}
}
.col-actions {
display: flex;
justify-content: flex-end;
}
}
}
:deep(.role-description) {
color: var(--color-text-lighter);
font-size: 0.9rem;
}
}
</style>

View File

@@ -22,117 +22,78 @@
</NcEmptyContent>
<!-- User list -->
<div v-else-if="users.length > 0" class="users-content">
<div class="users-table">
<div class="table-header">
<div class="col-user">{{ strings.user }}</div>
<div class="col-posts">{{ strings.posts }}</div>
<div class="col-roles">{{ strings.roles }}</div>
<div class="col-joined">{{ strings.joined }}</div>
<div class="col-status">{{ strings.status }}</div>
</div>
<AdminTable
v-else-if="users.length > 0"
:columns="tableColumns"
:rows="users"
row-key="userId"
:has-actions="true"
:actions-label="strings.actions"
:row-class="(user) => ({ 'is-deleted': user.isDeleted })"
>
<template #cell-user="{ row }">
<UserInfo :user-id="row.userId" :display-name="row.displayName" :avatar-size="40">
<template #meta>
<div class="user-id muted">@{{ row.userId }}</div>
</template>
</UserInfo>
</template>
<div
v-for="user in users"
:key="user.userId"
class="table-row"
:class="{ 'is-deleted': user.isDeleted }"
>
<div class="col-user">
<UserInfo :user-id="user.userId" :display-name="user.displayName" :avatar-size="40">
<template #meta>
<div class="user-id muted">@{{ user.userId }}</div>
</template>
</UserInfo>
<template #cell-posts="{ row }">
<div class="post-stats">
<div class="stat-item">
<span class="stat-value">{{ row.threadCount }}</span>
<span class="stat-label muted">threads</span>
</div>
<div class="col-posts">
<div class="post-stats">
<div class="stat-item">
<span class="stat-value">{{ user.threadCount }}</span>
<span class="stat-label muted">threads</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ user.postCount }}</span>
<span class="stat-label muted">posts</span>
</div>
</div>
</div>
<div class="col-roles">
<div v-if="editingUserId === user.userId" class="roles-editor">
<NcSelect
v-model="editingRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
:multiple="true"
label="name"
track-by="id"
input-label="name"
class="roles-select"
/>
<div class="edit-actions">
<NcButton
@click="cancelEdit"
:aria-label="strings.cancel"
:title="strings.cancel"
>
<template #icon>
<CloseIcon :size="20" />
</template>
</NcButton>
<NcButton
variant="primary"
@click="saveRoles(user.userId)"
:aria-label="strings.save"
:title="strings.save"
>
<template #icon>
<CheckIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div v-else class="roles-display">
<div class="roles-list">
<span
v-for="roleId in user.roles"
:key="roleId"
class="role-badge"
:class="getRoleBadgeClass(roleId)"
>
{{ getRoleName(roleId) }}
</span>
<span v-if="user.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
</div>
<NcButton
@click="startEdit(user.userId, user.roles)"
:aria-label="strings.edit"
:title="strings.edit"
>
<template #icon>
<PencilIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div class="col-joined">
<NcDateTime :timestamp="user.createdAt * 1000" />
</div>
<div class="col-status">
<span v-if="user.isDeleted" class="status-badge status-deleted">
{{ strings.deleted }}
</span>
<span v-else class="status-badge status-active">
{{ strings.active }}
</span>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ row.postCount }}</span>
<span class="stat-label muted">posts</span>
</div>
</div>
</div>
</div>
</template>
<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>
<span v-if="row.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
</div>
</template>
<template #cell-joined="{ row }">
<NcDateTime :timestamp="row.createdAt * 1000" />
</template>
<template #cell-status="{ row }">
<span v-if="row.isDeleted" class="status-badge status-deleted">
{{ strings.deleted }}
</span>
<span v-else class="status-badge status-active">
{{ strings.active }}
</span>
</template>
<template #actions="{ row }">
<NcActions variant="secondary">
<NcActionButton
@click="startEdit(row.userId, row.roles)"
:aria-label="strings.editRoles"
:title="strings.editRoles"
>
<template #icon>
<PencilIcon :size="20" />
</template>
</NcActionButton>
</NcActions>
</template>
</AdminTable>
<!-- Empty state -->
<NcEmptyContent
@@ -141,6 +102,30 @@
:description="strings.emptyDesc"
class="mt-16"
/>
<!-- Edit Roles Dialog -->
<NcDialog v-if="editingUserId !== null" :name="strings.editRolesTitle" @close="cancelEdit">
<div class="edit-roles-dialog">
<NcSelect
v-model="editingRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
:multiple="true"
label="name"
:input-label="strings.selectRoles"
track-by="id"
class="roles-select"
/>
</div>
<template #actions>
<NcButton @click="cancelEdit">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" @click="saveRoles(editingUserId)">
{{ strings.save }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
@@ -148,14 +133,16 @@
<script lang="ts">
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
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 AdminTable, { type TableColumn } from '@/components/AdminTable.vue'
import PencilIcon from '@icons/Pencil.vue'
import CheckIcon from '@icons/Check.vue'
import CloseIcon from '@icons/Close.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import { ocs } from '@/axios'
@@ -183,16 +170,18 @@ export default defineComponent({
name: 'AdminUserList',
components: {
NcButton,
NcActions,
NcActionButton,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
NcSelect,
NcDialog,
UserInfo,
AdminTable,
PageWrapper,
PageHeader,
PencilIcon,
CheckIcon,
CloseIcon,
},
data() {
return {
@@ -217,11 +206,13 @@ export default defineComponent({
roles: t('forum', 'Roles'),
joined: t('forum', 'Joined'),
status: t('forum', 'Status'),
actions: t('forum', 'Actions'),
active: t('forum', 'Active'),
deleted: t('forum', 'Deleted'),
noRoles: t('forum', 'No roles'),
selectRoles: t('forum', 'Select roles'),
edit: t('forum', 'Edit roles'),
editRoles: t('forum', 'Edit roles'),
editRolesTitle: t('forum', 'Edit User Roles'),
save: t('forum', 'Save'),
cancel: t('forum', 'Cancel'),
},
@@ -234,6 +225,15 @@ export default defineComponent({
name: role.name,
}))
},
tableColumns(): TableColumn[] {
return [
{ key: 'user', label: this.strings.user, minWidth: '200px' },
{ key: 'posts', label: this.strings.posts, minWidth: '160px' },
{ key: 'roles', label: this.strings.roles, minWidth: '150px' },
{ key: 'joined', label: this.strings.joined, minWidth: '120px' },
{ key: 'status', label: this.strings.status, minWidth: '80px' },
]
},
},
created() {
this.refresh()
@@ -356,168 +356,104 @@ export default defineComponent({
justify-content: center;
}
.page-header {
margin-bottom: 24px;
// Row-specific styling
:deep(.is-deleted > div) {
opacity: 0.6;
}
h2 {
margin: 0 0 6px 0;
// Custom cell content styling
.user-id {
font-size: 0.85rem;
}
.post-stats {
display: flex;
align-items: center;
gap: 8px;
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
.stat-value {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.stat-divider {
color: var(--color-text-maxcontrast);
font-weight: 300;
}
}
.users-content {
.users-table {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border);
border-radius: 8px;
overflow: hidden;
.roles-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
.table-header,
.table-row {
display: grid;
grid-template-columns: 2fr 100px 2fr 150px 100px;
gap: 16px;
padding: 16px;
background: var(--color-main-background);
align-items: center;
.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);
}
.table-header {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
&.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);
background: var(--color-background-hover);
}
}
}
.table-row {
&:hover {
background: var(--color-background-hover);
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&.is-deleted {
opacity: 0.6;
}
&.status-active {
background: var(--color-success-light);
color: var(--color-success-dark);
}
.col-user {
.user-id {
font-size: 0.85rem;
}
}
&.status-deleted {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
.col-posts {
.post-stats {
display: flex;
align-items: center;
gap: 8px;
.edit-roles-dialog {
padding: 16px 0;
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
.stat-value {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.stat-divider {
color: var(--color-text-maxcontrast);
font-weight: 300;
}
}
}
.col-roles {
.roles-editor {
display: flex;
align-items: center;
gap: 8px;
.roles-select {
flex: 1;
min-width: 200px;
}
.edit-actions {
display: flex;
gap: 4px;
}
}
.roles-display {
display: flex;
align-items: center;
gap: 8px;
.roles-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
.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);
}
}
}
}
}
.col-status {
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&.status-active {
background: var(--color-success-light);
color: var(--color-success-dark);
}
&.status-deleted {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
}
}
.roles-select {
width: 100%;
min-width: 300px;
}
}
}

View File

@@ -5,7 +5,8 @@ use OCP\Util;
/* @var array $_ */
$script = $_['script'];
Util::addScript(Application::APP_ID, Application::JS_DIR . "/forum-$script");
Util::addStyle(Application::APP_ID, Application::CSS_DIR . '/forum-style');
$style = $_['style'];
Util::addScript(Application::APP_ID, Application::JS_DIR . "/$script");
Util::addStyle(Application::APP_ID, Application::CSS_DIR . "/$style");
?>
<div id="forum-app"></div>

View File

@@ -1 +1 @@
0.5.0
0.7.0

View File

@@ -1,5 +1,33 @@
import { createAppConfig } from '@nextcloud/vite-config'
import path from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
const manualChunksList = [
'emoji-mart-vue-fast',
'date-fns',
'lodash',
'floating-vue',
'vue-material-design-icons',
]
const manualChunksGroups = {
vue: ['vue-router', 'vue'],
}
const nextcloudSharedList = [
'auth',
'axios',
'browser-storage',
'capabilities',
'event-bus',
'files',
'initial-state',
'l10n',
'logger',
'paths',
'router',
'sharing',
]
// https://vite.dev/config/
export default createAppConfig(
@@ -15,51 +43,58 @@ export default createAppConfig(
'@': path.resolve(__dirname, 'src'),
},
},
plugins: [
visualizer({
open: process.env.VITE_BUILD_ANALYZE === 'true',
filename: 'stats.html',
template: 'treemap',
}),
],
build: {
outDir: '../dist',
manifest: true,
cssCodeSplit: false,
rollupOptions: {
output: {
entryFileNames: 'js/[name]-[hash].mjs',
chunkFileNames: 'js/[name]-[hash].mjs',
assetFileNames: '[ext]/[name]-[hash].[ext]',
manualChunks(id) {
if (id.includes('node_modules')) {
const manualChunks = [
'date-fns',
'lodash',
'dompurify',
'linkifyjs',
'floating-vue',
'focus-trap',
'floating-ui',
'vue-router',
'vue-material-design-icons',
'vue',
'axios',
]
// Get the part after the last 'node_modules/' to handle pnpm structure
const parts = id.split('node_modules/')
const pkgPath = parts[parts.length - 1]
// Match @nextcloud/xxx packages
const scopedNextcloudMatch = pkgPath.match(/^@nextcloud\/([^/]+)/)
if (scopedNextcloudMatch) {
return `nextcloud-${scopedNextcloudMatch[1]}`
}
// Match nextcloud-xxx packages (without @ scope)
const nextcloudMatch = pkgPath.match(/^nextcloud-([^/]+)/)
if (nextcloudMatch) {
return `nextcloud-${nextcloudMatch[1]}`
}
// Handle other common packages
for (const chunk of manualChunks) {
if (pkgPath.includes(chunk)) {
return chunk
}
}
return 'vendor' // fallback for other deps
if (!id.includes('node_modules')) {
return
}
// Parse package path
const parts = id.split('node_modules/')
const pkgPath = parts[parts.length - 1]
// Check for @nextcloud/xxx or nextcloud-xxx
const ncMatch = pkgPath.match(/^@?nextcloud[/-]([^/]+)/)
// Get the package name (e.g., 'auth', 'vue', 'axios')
const ncPkgName = ncMatch?.[1]
if (ncPkgName) {
if (nextcloudSharedList.includes(ncPkgName)) {
return 'nextcloud-common'
}
return `nextcloud-${ncPkgName}`
}
for (const chunk of manualChunksList) {
if (pkgPath.includes(chunk)) {
return chunk
}
}
for (const [groupName, groupPackages] of Object.entries(manualChunksGroups)) {
if (groupPackages.some((pkg) => pkgPath.includes(pkg))) {
return groupName
}
}
// Fallback
return 'vendor'
},
},
},