mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ba4efb92c | |||
| d6d4694ce0 | |||
| 84edf8ecbe | |||
| 96a42525d3 | |||
| 84fe339fc0 | |||
| ce6b334dd3 | |||
| 92418cc543 | |||
| 2753ecfefb | |||
| 53130ca10a | |||
| e97302b861 | |||
| c8ca4f9168 | |||
| c16e804d16 | |||
| 22f9b78b1b | |||
| 432c31f6e2 | |||
| 46367aa0d8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ tsconfig.app.tsbuildinfo
|
||||
.env.keys
|
||||
.envrc
|
||||
tests/.phpunit.result.cache
|
||||
stats.html
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.5.0"}
|
||||
{".":"0.7.0"}
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
3
Makefile
3
Makefile
@@ -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" \
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
91
lib/Command/SetRole.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
54
lib/Cron/RebuildStatsTask.php
Normal file
54
lib/Cron/RebuildStatsTask.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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}", [
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
120
pnpm-lock.yaml
generated
@@ -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 |
205
src/components/AdminTable.vue
Normal file
205
src/components/AdminTable.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
3
src/components/LazyEmojiPicker.ts
Normal file
3
src/components/LazyEmojiPicker.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
export default defineAsyncComponent(() => import('@nextcloud/vue/components/NcEmojiPicker'))
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.5.0
|
||||
0.7.0
|
||||
|
||||
111
vite.config.ts
111
vite.config.ts
@@ -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'
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user