Compare commits

...

55 Commits

Author SHA1 Message Date
8ba4efb92c chore(master): release 0.7.0 2025-11-20 14:24:48 +02:00
d6d4694ce0 fix: build excluded files 2025-11-20 14:22:04 +02:00
84edf8ecbe feat: weekly task now calculates category/thread post counts 2025-11-20 11:18:41 +02:00
96a42525d3 feat: add forum:set-role occ command 2025-11-20 11:07:03 +02:00
84fe339fc0 chore(master): release 0.6.0 2025-11-20 10:25:54 +02:00
ce6b334dd3 build: clean up asset loaders 2025-11-20 10:14:15 +02:00
92418cc543 build: improve asset chunk logic 2025-11-20 04:37:46 +02:00
2753ecfefb build: improve asset/script loading 2025-11-20 04:37:17 +02:00
53130ca10a chore: update scaffolds 2025-11-20 04:35:38 +02:00
e97302b861 build: fix build errors 2025-11-20 01:58:19 +02:00
c8ca4f9168 feat(AdminCategoryEdit): pre-populate role dropdowns with default roles 2025-11-20 01:52:05 +02:00
c16e804d16 fix(AdminUserList): empty state display condition 2025-11-20 01:46:57 +02:00
22f9b78b1b fix(AdminCategoryList): list spacing 2025-11-20 01:46:40 +02:00
432c31f6e2 feat(AdminTable): improve users/role tables design 2025-11-20 01:41:46 +02:00
46367aa0d8 docs: update screenshot 2025-11-19 23:40:57 +02:00
f4fe09fae3 chore(master): release 0.5.0 2025-11-19 20:36:07 +02:00
5c66f44da5 fix(AdminCategoryList): allow deleting empty categories/headers 2025-11-19 20:27:11 +02:00
363e04b4e9 feat(AdminCategoryEdit): auto-populate slug on create 2025-11-19 20:08:46 +02:00
1fd59c1f04 fix: improve admin role assignment to users 2025-11-19 19:43:25 +02:00
5391d8fffe chore(master): release 0.4.0 2025-11-19 09:32:37 +02:00
b0bfbbccdf feat(BBCodeEditor): add attachment disclaimer 2025-11-19 02:54:07 +02:00
9525ebfb97 fix(ThreadCard): mobile responsiveness 2025-11-19 02:54:07 +02:00
67e9fb9f8c fix(ProfileView): mobile responsiveness 2025-11-19 02:54:06 +02:00
a36da9f882 feat(AppNavigation): save collapse state to local storage 2025-11-19 02:54:06 +02:00
c0762158d7 fix: mobile responsiveness 2025-11-19 02:54:06 +02:00
479cdbbba5 refactor: clean up AppNavigation active logic 2025-11-19 02:54:05 +02:00
255a5cf53d feat(BBCodeToolbar): add emoji picker button 2025-11-19 02:54:05 +02:00
feeefa2926 feat(PostReactions): use Nextcloud emoji picker 2025-11-19 02:54:03 +02:00
f49561ccca chore(master): release 0.3.0 2025-11-18 10:31:32 +02:00
e59a6f4dc7 feat: add skeleton component + update categories header ui 2025-11-18 10:26:51 +02:00
9719f518e2 feat: load forum title/subtitle from public endpoint 2025-11-18 10:26:50 +02:00
2d10b461c0 feat: add page header component 2025-11-18 10:26:50 +02:00
2264289b56 refactor: move AppToolbar position to PageWrapper slot 2025-11-18 02:44:59 +02:00
3ef545dcc9 refactor: add PageWrapper component 2025-11-18 02:21:08 +02:00
fb905f8d15 docs: add release to README.md 2025-11-18 02:13:19 +02:00
278f1b3cc4 feat: user preferences page & auto thread subs pref 2025-11-18 01:38:57 +02:00
5ee8a16aa1 fix: user stats post is_first_post counts 2025-11-17 18:22:41 +02:00
a1671baf2d chore(master): release 0.2.1 2025-11-17 18:15:40 +02:00
71ee133ac6 fix: unread counts for deleted posts 2025-11-17 18:13:19 +02:00
1add8db287 fix: thread card hover styles 2025-11-17 17:52:34 +02:00
e1e3ede1d8 chore(master): release 0.2.0 2025-11-17 10:09:24 +02:00
9833e51997 fix: admin/mod post permissions 2025-11-17 10:01:42 +02:00
664ee53670 fix: user avatar container size 2025-11-17 09:45:40 +02:00
7a80c19613 chore: fix ts errors 2025-11-17 03:18:49 +02:00
8cc34d9d7a feat: update thread card user info display 2025-11-17 03:16:01 +02:00
364226fdc8 fix: create user stats for existing users 2025-11-17 03:10:09 +02:00
11aa3af887 feat: unify user info component 2025-11-17 03:05:55 +02:00
0de120f2bf feat: rebuild user stats task & command 2025-11-17 02:42:06 +02:00
e590f73fc0 fix: user stats table 2025-11-17 02:41:54 +02:00
4ca6388923 feat: add emoji picker close icon 2025-11-17 01:48:25 +02:00
cdecdce9d1 fix: emoji picker position 2025-11-17 01:44:53 +02:00
bf59b47b2a build: exclude openapi from precommit formatting 2025-11-17 01:32:49 +02:00
2fbe180d5e feat: thread subscriptions & notifications 2025-11-17 01:27:17 +02:00
d16288f237 fix: default support category sort order 2025-11-16 23:15:24 +02:00
6ba8034b75 fix: autoload 2025-11-16 16:35:45 +02:00
84 changed files with 6850 additions and 3553 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,6 +1,10 @@
module.exports = {
'*.{ts,vue}': ['eslint --fix'],
'*.{scss,vue,ts,md,json}': ['prettier --write'],
'*.{scss,vue,ts,md}': ['prettier --write'],
'*.json': (files) => {
const filtered = files.filter(file => !file.includes('openapi.json'));
return filtered.length > 0 ? `prettier --write ${filtered.join(' ')}` : [];
},
'*.php': [() => 'make php-cs-fixer'],
'*Controller.php': [() => 'make openapi', () => 'git add openapi.json'],
}

View File

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

View File

@@ -1,5 +1,107 @@
# 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)
### Features
* **AdminCategoryEdit:** auto-populate slug on create ([363e04b](https://github.com/chenasraf/nextcloud-forum/commit/363e04b4e93f896648fa2d58d4d5718cc924236c))
### Bug Fixes
* **AdminCategoryList:** allow deleting empty categories/headers ([5c66f44](https://github.com/chenasraf/nextcloud-forum/commit/5c66f44da520fce0d35f384e13452f8cae1427ab))
* improve admin role assignment to users ([1fd59c1](https://github.com/chenasraf/nextcloud-forum/commit/1fd59c1f0462e02aa49703191365a9d564f5c8e9))
## [0.4.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.3.0...v0.4.0) (2025-11-19)
### Features
* **AppNavigation:** save collapse state to local storage ([a36da9f](https://github.com/chenasraf/nextcloud-forum/commit/a36da9f8822aa6b091e34d82cce8b56a86547b39))
* **BBCodeEditor:** add attachment disclaimer ([b0bfbbc](https://github.com/chenasraf/nextcloud-forum/commit/b0bfbbccdf04bd92d374ed31e404c9fadc23f51b))
* **BBCodeToolbar:** add emoji picker button ([255a5cf](https://github.com/chenasraf/nextcloud-forum/commit/255a5cf53dcce38c9356b30713a76e95592abe44))
* **PostReactions:** use Nextcloud emoji picker ([feeefa2](https://github.com/chenasraf/nextcloud-forum/commit/feeefa2926589cbd0c62053f1700c9bfb6bca545))
### Bug Fixes
* mobile responsiveness ([c076215](https://github.com/chenasraf/nextcloud-forum/commit/c0762158d75e6eebf0ac77a512218cf7b4119a97))
* **ProfileView:** mobile responsiveness ([67e9fb9](https://github.com/chenasraf/nextcloud-forum/commit/67e9fb9f8cdb9d1ada660b1d90e8de5aa35051de))
* **ThreadCard:** mobile responsiveness ([9525ebf](https://github.com/chenasraf/nextcloud-forum/commit/9525ebfb9705e66281898af7fcb733ba1ae8208c))
## [0.3.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.1...v0.3.0) (2025-11-18)
### Features
* add page header component ([2d10b46](https://github.com/chenasraf/nextcloud-forum/commit/2d10b461c018160d63ed6e63479e1488ba8da38e))
* add skeleton component + update categories header ui ([e59a6f4](https://github.com/chenasraf/nextcloud-forum/commit/e59a6f4dc7b60ad0b370b801d541f0007d1896c3))
* load forum title/subtitle from public endpoint ([9719f51](https://github.com/chenasraf/nextcloud-forum/commit/9719f518e2b1a9dced781431a6b0d4123aef952c))
* user preferences page & auto thread subs pref ([278f1b3](https://github.com/chenasraf/nextcloud-forum/commit/278f1b3cc48b6d2e74c383dec34015e3e3cd1e81))
### Bug Fixes
* user stats post is_first_post counts ([5ee8a16](https://github.com/chenasraf/nextcloud-forum/commit/5ee8a16aa13510c7b6a6b48238bc156c27045e7b))
## [0.2.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.0...v0.2.1) (2025-11-17)
### Bug Fixes
* thread card hover styles ([1add8db](https://github.com/chenasraf/nextcloud-forum/commit/1add8db28775d2d13d8b2eb9428a90eb99b32ae8))
* unread counts for deleted posts ([71ee133](https://github.com/chenasraf/nextcloud-forum/commit/71ee133ac6b59f9005918594f7e668031b8224fa))
## [0.2.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.7...v0.2.0) (2025-11-17)
### Features
* add emoji picker close icon ([4ca6388](https://github.com/chenasraf/nextcloud-forum/commit/4ca6388923299751a251f56785c2b29dc2dd75dd))
* rebuild user stats task & command ([0de120f](https://github.com/chenasraf/nextcloud-forum/commit/0de120f2bf88bd377aa13a760f29f1b46ece98e9))
* thread subscriptions & notifications ([2fbe180](https://github.com/chenasraf/nextcloud-forum/commit/2fbe180d5e8a6e6fdd02b3506896f9355d6bef22))
* unify user info component ([11aa3af](https://github.com/chenasraf/nextcloud-forum/commit/11aa3af887f17c3236ff8abcc8ef1d3b15ee03c2))
* update thread card user info display ([8cc34d9](https://github.com/chenasraf/nextcloud-forum/commit/8cc34d9d7a0711d43b938b9fd686a8ea682160cf))
### Bug Fixes
* admin/mod post permissions ([9833e51](https://github.com/chenasraf/nextcloud-forum/commit/9833e519973da5ff059ef0346333bdc96d73c072))
* autoload ([6ba8034](https://github.com/chenasraf/nextcloud-forum/commit/6ba8034b7535d1c449e8b75d5645398f948b7941))
* create user stats for existing users ([364226f](https://github.com/chenasraf/nextcloud-forum/commit/364226fdc84713162b1b59d3ec17455177a7ba81))
* default support category sort order ([d16288f](https://github.com/chenasraf/nextcloud-forum/commit/d16288f237e07ad7d3a5726029de39c7bee7b8da))
* emoji picker position ([cdecdce](https://github.com/chenasraf/nextcloud-forum/commit/cdecdce9d18828e227be0994b9ccf065eba9c831))
* user avatar container size ([664ee53](https://github.com/chenasraf/nextcloud-forum/commit/664ee536705bd2d8fab64470a2a2600ab30e3d26))
* user stats table ([e590f73](https://github.com/chenasraf/nextcloud-forum/commit/e590f73fc02f32c6d0f908e895441f4405240ec7))
## [0.1.7](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.6...v0.1.7) (2025-11-16)

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

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

11
appinfo/console.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
use OCA\Forum\Command\TestNotifier;
/** @var Symfony\Component\Console\Application $application */
$application->add(\OC::$server->get(TestNotifier::class));

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.1.7</version>
<version>0.7.0</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>
@@ -55,6 +55,14 @@ The forum integrates seamlessly with your Nextcloud instance, using your existin
<dependencies>
<nextcloud min-version="29" max-version="33"/>
</dependencies>
<background-jobs>
<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">
<name>Forum</name>

8
composer/autoload.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';

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

@@ -6,6 +6,7 @@ namespace OCA\Forum\AppInfo;
use OCA\Forum\Listener\UserEventListener;
use OCA\Forum\Middleware\PermissionMiddleware;
use OCA\Forum\Notification\Notifier;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -23,12 +24,6 @@ class Application extends App implements IBootstrap {
/** @psalm-suppress PossiblyUnusedMethod */
public function __construct() {
parent::__construct(self::APP_ID);
// Load Composer dependencies
$autoloadFile = __DIR__ . '/../../vendor/autoload.php';
if (file_exists($autoloadFile)) {
require_once $autoloadFile;
}
}
public function register(IRegistrationContext $context): void {
@@ -39,11 +34,41 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(UserCreatedEvent::class, UserEventListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserEventListener::class);
$context->registerEventListener(UserChangedEvent::class, UserEventListener::class);
// Register notification notifier
$context->registerNotifierService(Notifier::class);
}
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

@@ -0,0 +1,40 @@
<?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\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 StatsService $statsService,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('forum:rebuild-user-stats')
->setDescription('Rebuild user statistics for all users in the system');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('<info>Rebuilding user statistics for all users...</info>');
$result = $this->statsService->rebuildAllUserStats();
$output->writeln(sprintf('Processed %d users', $result['users']));
$output->writeln(sprintf('Created %d new user stats', $result['created']));
$output->writeln(sprintf('Updated %d existing user stats', $result['updated']));
$output->writeln('<info>User statistics rebuilt successfully!</info>');
return 0;
}
}

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

@@ -0,0 +1,81 @@
<?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\Notification\Notifier;
use OCP\L10N\IFactory;
use OCP\Notification\IManager as INotificationManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class TestNotifier extends Command {
public function __construct(
private INotificationManager $notificationManager,
private IFactory $l10nFactory,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('forum:test-notifier')
->setDescription('Test the forum notification system');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$output->writeln('<info>Testing Forum Notifier...</info>');
// Instantiate the notifier
$notifier = new Notifier($this->l10nFactory);
$output->writeln('✓ Notifier instantiated successfully');
$output->writeln(' ID: ' . $notifier->getID());
$output->writeln(' Name: ' . $notifier->getName());
// Create a test notification (matching production structure)
$notification = $this->notificationManager->createNotification();
$notification->setApp('forum')
->setUser('admin')
->setDateTime(new \DateTime())
->setObject('thread', '1')
->setSubject('new_posts', [
'threadId' => 1,
'threadTitle' => 'Test Thread',
'threadSlug' => 'test-thread',
'lastPostId' => 1,
'postCount' => 1,
])
->setLink('http://localhost/apps/forum/t/test-thread')
->setIcon('http://localhost/apps/forum/img/app-dark.svg');
$output->writeln('✓ Test notification created');
// Try to prepare it
$prepared = $notifier->prepare($notification, 'en');
$output->writeln('✓ Notification prepared successfully');
$output->writeln(' Subject: ' . $prepared->getParsedSubject());
$output->writeln(' Link: ' . $prepared->getLink());
$output->writeln(' Icon: ' . $prepared->getIcon());
$output->writeln('');
$output->writeln('<info>All tests passed! The notifier is working correctly.</info>');
return 0;
} catch (\Exception $e) {
$output->writeln('<error>✗ Error: ' . $e->getMessage() . '</error>');
$output->writeln('<error>Trace:</error>');
$output->writeln($e->getTraceAsString());
return 1;
}
}
}

View File

@@ -7,7 +7,6 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\PostMapper;
@@ -21,6 +20,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
@@ -41,6 +41,7 @@ class AdminController extends OCSController {
private IUserSession $userSession,
private IConfig $config,
private LoggerInterface $logger,
private IL10N $l10n,
) {
parent::__construct($appName, $request);
}
@@ -171,8 +172,8 @@ class AdminController extends OCSController {
public function getSettings(): DataResponse {
try {
$settings = [
'title' => $this->config->getAppValue(Application::APP_ID, 'title', 'Forum'),
'subtitle' => $this->config->getAppValue(Application::APP_ID, 'subtitle', 'Welcome to the forum'),
'title' => $this->config->getSystemValueString('title', $this->l10n->t('Forum')),
'subtitle' => $this->config->getSystemValueString('subtitle', $this->l10n->t('Welcome to the forum!')),
];
return new DataResponse($settings);
@@ -197,17 +198,17 @@ class AdminController extends OCSController {
public function updateSettings(?string $title = null, ?string $subtitle = null): DataResponse {
try {
if ($title !== null) {
$this->config->setAppValue(Application::APP_ID, 'title', $title);
$this->config->setSystemValue('title', $title);
}
if ($subtitle !== null) {
$this->config->setAppValue(Application::APP_ID, 'subtitle', $subtitle);
$this->config->setSystemValue('subtitle', $subtitle);
}
// Return updated settings
$settings = [
'title' => $this->config->getAppValue(Application::APP_ID, 'title', 'Forum'),
'subtitle' => $this->config->getAppValue(Application::APP_ID, 'subtitle', 'Welcome to the forum'),
'title' => $this->config->getSystemValueString('title', $this->l10n->t('Forum')),
'subtitle' => $this->config->getSystemValueString('subtitle', $this->l10n->t('Welcome to the forum!')),
];
return new DataResponse($settings);

View File

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

@@ -17,6 +17,7 @@ use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\BBCodeService;
use OCA\Forum\Service\NotificationService;
use OCA\Forum\Service\PermissionService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -41,6 +42,7 @@ class PostController extends OCSController {
private BBCodeMapper $bbCodeMapper,
private PermissionService $permissionService,
private ReadMarkerMapper $readMarkerMapper,
private NotificationService $notificationService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -268,6 +270,14 @@ class PostController extends OCSController {
// Don't fail the request if category update fails
}
// Notify registered users about the new post
try {
$this->notificationService->notifyThreadSubscribers($threadId, $createdPost->getId(), $user->getUID());
} catch (\Exception $e) {
$this->logger->warning('Failed to send notifications for new post: ' . $e->getMessage());
// Don't fail the request if notification sending fails
}
return new DataResponse(Post::enrichPostContent($createdPost), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating post: ' . $e->getMessage());
@@ -295,12 +305,13 @@ class PostController extends OCSController {
$post = $this->postMapper->find($id);
// Check if user is the author OR has moderator permission
// Check if user is the author OR has moderator permission OR is admin/moderator
$isAuthor = $post->getAuthorId() === $user->getUID();
$categoryId = $this->permissionService->getCategoryIdFromPost($id);
$isModerator = $this->permissionService->hasCategoryPermission($user->getUID(), $categoryId, 'canModerate');
$isAdminOrMod = $this->permissionService->hasAdminOrModeratorRole($user->getUID());
if (!$isAuthor && !$isModerator) {
if (!$isAuthor && !$isModerator && !$isAdminOrMod) {
return new DataResponse(['error' => 'Insufficient permissions to edit this post'], Http::STATUS_FORBIDDEN);
}
@@ -341,12 +352,13 @@ class PostController extends OCSController {
$post = $this->postMapper->find($id);
// Check if user is the author OR has moderator permission
// Check if user is the author OR has moderator permission OR is admin/moderator
$isAuthor = $post->getAuthorId() === $user->getUID();
$categoryId = $this->permissionService->getCategoryIdFromPost($id);
$isModerator = $this->permissionService->hasCategoryPermission($user->getUID(), $categoryId, 'canModerate');
$isAdminOrMod = $this->permissionService->hasAdminOrModeratorRole($user->getUID());
if (!$isAuthor && !$isModerator) {
if (!$isAuthor && !$isModerator && !$isAdminOrMod) {
return new DataResponse(['error' => 'Insufficient permissions to delete this post'], Http::STATUS_FORBIDDEN);
}
@@ -355,17 +367,53 @@ class PostController extends OCSController {
$post->setUpdatedAt(time());
$this->postMapper->update($post);
// Update thread post count
// Update thread post count and lastPostId
try {
$thread = $this->threadMapper->find($post->getThreadId());
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
$thread->setUpdatedAt(time());
// If the deleted post was the last post, update lastPostId to the previous non-deleted post
if ($thread->getLastPostId() === $post->getId()) {
// Find the latest non-deleted post in this thread (excluding the one being deleted)
$latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId());
if ($latestPost) {
$thread->setLastPostId($latestPost->getId());
} else {
// No other posts in thread, set to null (or keep first post ID)
$thread->setLastPostId(null);
}
}
$this->threadMapper->update($thread);
} catch (\Exception $e) {
$this->logger->warning('Failed to update thread post count after post deletion: ' . $e->getMessage());
$this->logger->warning('Failed to update thread after post deletion: ' . $e->getMessage());
// Don't fail the request if thread update fails
}
// Update user stats - decrement post count, and thread count if it's the first post
try {
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
// If this is the first post of a thread, also decrement thread count
if ($post->getIsFirstPost()) {
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
// Update category post count
try {
$category = $this->categoryMapper->find($categoryId);
$category->setPostCount(max(0, $category->getPostCount() - 1));
$this->categoryMapper->update($category);
} catch (\Exception $e) {
$this->logger->warning('Failed to update category post count after post deletion: ' . $e->getMessage());
// Don't fail the request if category update fails
}
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);

View File

@@ -8,6 +8,7 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Service\NotificationService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -23,6 +24,7 @@ class ReadMarkerController extends OCSController {
string $appName,
IRequest $request,
private ReadMarkerMapper $readMarkerMapper,
private NotificationService $notificationService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -136,6 +138,18 @@ class ReadMarkerController extends OCSController {
$lastReadPostId
);
// Dismiss notifications if the user has caught up with the thread
try {
$this->notificationService->dismissNotificationsIfRead(
$user->getUID(),
$threadId,
$lastReadPostId
);
} catch (\Exception $e) {
$this->logger->warning('Failed to dismiss notifications: ' . $e->getMessage());
// Don't fail the request if notification dismissal fails
}
return new DataResponse($marker->jsonSerialize());
} catch (\Exception $e) {
$this->logger->error('Error marking thread as read: ' . $e->getMessage());

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class SettingsController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private IConfig $config,
private LoggerInterface $logger,
private IL10N $l10n,
) {
parent::__construct($appName, $request);
}
/**
* Get public forum settings (title and subtitle)
*
* This endpoint is publicly accessible to all users.
* For admin-only settings, use AdminController::getSettings()
*
* @return DataResponse<Http::STATUS_OK, array{title: string, subtitle: string}, array{}>
*
* 200: Settings retrieved successfully
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/settings')]
public function getPublicSettings(): DataResponse {
try {
$settings = [
'title' => $this->config->getSystemValueString('title', $this->l10n->t('Forum')),
'subtitle' => $this->config->getSystemValueString('subtitle', $this->l10n->t('Welcome to the forum!')),
];
return new DataResponse($settings);
} catch (\Exception $e) {
$this->logger->error('Error fetching public settings: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch settings'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -13,7 +13,9 @@ use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\Thread;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\UserPreferencesService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -32,6 +34,8 @@ class ThreadController extends OCSController {
private CategoryMapper $categoryMapper,
private PostMapper $postMapper,
private UserStatsMapper $userStatsMapper,
private ThreadSubscriptionMapper $threadSubscriptionMapper,
private UserPreferencesService $userPreferencesService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -244,6 +248,20 @@ class ThreadController extends OCSController {
$this->logger->warning('Failed to update user stats: ' . $e->getMessage());
}
// Auto-subscribe the thread creator to receive notifications (if preference is enabled)
try {
$autoSubscribe = $this->userPreferencesService->getPreference(
$user->getUID(),
UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS
);
if ($autoSubscribe) {
$this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to subscribe thread creator: ' . $e->getMessage());
}
return new DataResponse($createdThread->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating thread: ' . $e->getMessage());
@@ -387,6 +405,18 @@ class ThreadController extends OCSController {
// Don't fail the request if category update fails
}
// Update author's user stats (decrement thread count and all posts in this thread)
try {
$this->userStatsMapper->decrementThreadCount($thread->getAuthorId());
// Decrement post count by the number of posts in this thread
if ($thread->getPostCount() > 0) {
$this->userStatsMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after thread deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
return new DataResponse([
'success' => true,
'categorySlug' => $categorySlug,

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Controller;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class ThreadSubscriptionController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private ThreadSubscriptionMapper $subscriptionMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Subscribe current user to a thread to receive notifications
*
* @param int $threadId Thread ID
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: User subscribed to thread
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/threads/{threadId}/subscribe')]
public function subscribe(int $threadId): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$subscription = $this->subscriptionMapper->subscribe($user->getUID(), $threadId);
return new DataResponse([
'success' => true,
'subscription' => $subscription->jsonSerialize(),
]);
} catch (\Exception $e) {
$this->logger->error('Error subscribing user to thread: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to subscribe to thread'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Unsubscribe current user from a thread
*
* @param int $threadId Thread ID
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: User unsubscribed from thread
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/threads/{threadId}/subscribe')]
public function unsubscribe(int $threadId): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$this->subscriptionMapper->unsubscribe($user->getUID(), $threadId);
return new DataResponse(['success' => true]);
} catch (\Exception $e) {
$this->logger->error('Error unsubscribing user from thread: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to unsubscribe from thread'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Check if current user is subscribed to a thread
*
* @param int $threadId Thread ID
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Subscription status returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/threads/{threadId}/subscribe')]
public function isSubscribed(int $threadId): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$isSubscribed = $this->subscriptionMapper->isUserSubscribed($user->getUID(), $threadId);
return new DataResponse(['isSubscribed' => $isSubscribed]);
} catch (\Exception $e) {
$this->logger->error('Error checking thread subscription status: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to check subscription status'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get all threads the current user is subscribed to
*
* @return DataResponse<Http::STATUS_OK, list<array<string, mixed>>, array{}>
*
* 200: Thread subscriptions returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/thread-subscriptions')]
public function getUserSubscriptions(): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$subscriptions = $this->subscriptionMapper->findByUserId($user->getUID());
return new DataResponse(array_map(fn ($r) => $r->jsonSerialize(), $subscriptions));
} catch (\Exception $e) {
$this->logger->error('Error fetching user thread subscriptions: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch thread subscriptions'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Controller;
use OCA\Forum\Service\UserPreferencesService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class UserPreferencesController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private UserPreferencesService $preferencesService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Get all user preferences
*
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: string}, array{}>
*
* 200: Preferences returned
* 401: User not authenticated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/user-preferences')]
public function index(): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$preferences = $this->preferencesService->getAllPreferences($user->getUID());
return new DataResponse($preferences);
} catch (\Exception $e) {
$this->logger->error('Error fetching user preferences: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch preferences'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update user preferences
*
* @param array<string, mixed> $preferences Key-value pairs of preferences to update
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Preferences updated
* 400: Invalid preference key or value
* 401: User not authenticated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/user-preferences')]
public function update(array $preferences): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$allPreferences = $this->preferencesService->updatePreferences($user->getUID(), $preferences);
return new DataResponse($allPreferences);
} catch (\InvalidArgumentException $e) {
return new DataResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\Exception $e) {
$this->logger->error('Error updating user preferences: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to update preferences'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

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

@@ -87,22 +87,26 @@ class PostMapper extends QBMapper {
public function findByAuthorId(string $authorId, int $limit = 50, int $offset = 0, bool $excludeFirstPosts = false): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
$qb->select('p.*')
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
$qb->expr()->eq('p.author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
if ($excludeFirstPosts) {
$qb->andWhere(
$qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
$qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
);
}
$qb->orderBy('created_at', 'DESC')
$qb->orderBy('p.created_at', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
return $this->findEntities($qb);
@@ -114,12 +118,16 @@ class PostMapper extends QBMapper {
public function findAll(): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
$qb->select('p.*')
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->orderBy('created_at', 'DESC');
->andWhere(
$qb->expr()->isNull('t.deleted_at')
)
->orderBy('p.created_at', 'DESC');
return $this->findEntities($qb);
}
@@ -129,9 +137,13 @@ class PostMapper extends QBMapper {
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
$result = $qb->executeQuery();
$row = $result->fetch();
@@ -145,10 +157,14 @@ class PostMapper extends QBMapper {
public function countSince(int $timestamp): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where($qb->expr()->gte('p.created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->andWhere(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
$result = $qb->executeQuery();
$row = $result->fetch();
@@ -156,6 +172,58 @@ class PostMapper extends QBMapper {
return (int)($row['count'] ?? 0);
}
/**
* Find the latest non-deleted post in a thread, excluding a specific post ID
*
* @param int $threadId Thread ID
* @param int|null $excludePostId Post ID to exclude (typically the one being deleted)
* @return Post|null Latest post or null if no posts found
*/
public function findLatestByThreadId(int $threadId, ?int $excludePostId = null): ?Post {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('deleted_at'));
if ($excludePostId !== null) {
$qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($excludePostId, IQueryBuilder::PARAM_INT)));
}
$qb->orderBy('created_at', 'DESC')
->setMaxResults(1);
try {
return $this->findEntity($qb);
} catch (DoesNotExistException $e) {
return null;
}
}
/**
* Count unread posts in a thread after a specific post ID
*
* @param int $threadId Thread ID
* @param int $afterPostId Post ID to count after (0 to count all posts)
* @return int Number of posts after the given post ID
*/
public function countUnreadInThread(int $threadId, int $afterPostId = 0): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('deleted_at'));
if ($afterPostId > 0) {
$qb->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($afterPostId, IQueryBuilder::PARAM_INT)));
}
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (int)($row['count'] ?? 0);
}
/**
* Search posts by content (replies only, excluding first posts)
*

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

@@ -114,6 +114,21 @@ class Thread extends Entity implements JsonSerializable {
$thread['categoryName'] = null;
}
// Add subscription status for the current user
try {
$userSession = \OC::$server->get(\OCP\IUserSession::class);
$user = $userSession->getUser();
if ($user) {
$subscriptionMapper = \OC::$server->get(\OCA\Forum\Db\ThreadSubscriptionMapper::class);
$thread['isSubscribed'] = $subscriptionMapper->isUserSubscribed($user->getUID(), $thread['id']);
} else {
$thread['isSubscribed'] = false;
}
} catch (\Exception $e) {
// If there's an error checking subscription, default to false
$thread['isSubscribed'] = false;
}
return $thread;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method int getId()
* @method void setId(int $value)
* @method string getUserId()
* @method void setUserId(string $value)
* @method int getThreadId()
* @method void setThreadId(int $value)
* @method int getCreatedAt()
* @method void setCreatedAt(int $value)
*/
class ThreadSubscription extends Entity implements JsonSerializable {
protected $userId;
protected $threadId;
protected $createdAt;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('userId', 'string');
$this->addType('threadId', 'integer');
$this->addType('createdAt', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'userId' => $this->getUserId(),
'threadId' => $this->getThreadId(),
'createdAt' => $this->getCreatedAt(),
];
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use OCA\Forum\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<ThreadSubscription>
*/
class ThreadSubscriptionMapper extends QBMapper {
public function __construct(
IDBConnection $db,
) {
parent::__construct($db, Application::tableName('forum_thread_subs'), ThreadSubscription::class);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(int $id): ThreadSubscription {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function findByUserAndThread(string $userId, int $threadId): ThreadSubscription {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
/**
* Check if a user is subscribed to a thread
*/
public function isUserSubscribed(string $userId, int $threadId): bool {
try {
$this->findByUserAndThread($userId, $threadId);
return true;
} catch (DoesNotExistException $e) {
return false;
}
}
/**
* Get all subscribed users for a thread
*
* @return array<ThreadSubscription>
*/
public function findByThread(int $threadId): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
);
return $this->findEntities($qb);
}
/**
* Get all thread subscriptions for a user
*
* @return array<ThreadSubscription>
*/
public function findByUserId(string $userId): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
);
return $this->findEntities($qb);
}
/**
* Subscribe a user to a thread
*/
public function subscribe(string $userId, int $threadId): ThreadSubscription {
// Check if already subscribed
if ($this->isUserSubscribed($userId, $threadId)) {
return $this->findByUserAndThread($userId, $threadId);
}
// Create new subscription
$subscription = new ThreadSubscription();
$subscription->setUserId($userId);
$subscription->setThreadId($threadId);
$subscription->setCreatedAt(time());
return $this->insert($subscription);
}
/**
* Unsubscribe a user from a thread
*/
public function unsubscribe(string $userId, int $threadId): void {
try {
$subscription = $this->findByUserAndThread($userId, $threadId);
$this->delete($subscription);
} catch (DoesNotExistException $e) {
// Already not subscribed, nothing to do
}
}
/**
* @return array<ThreadSubscription>
*/
public function findAll(): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName());
return $this->findEntities($qb);
}
}

View File

@@ -11,6 +11,8 @@ use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method int getId()
* @method void setId(int $id)
* @method string getUserId()
* @method void setUserId(string $userId)
* @method int getPostCount()
@@ -27,6 +29,7 @@ use OCP\AppFramework\Db\Entity;
* @method void setUpdatedAt(int $updatedAt)
*/
class UserStats extends Entity implements JsonSerializable {
public $id;
protected string $userId = '';
protected int $postCount = 0;
protected int $threadCount = 0;
@@ -36,7 +39,7 @@ class UserStats extends Entity implements JsonSerializable {
protected int $updatedAt = 0;
public function __construct() {
// User ID is the primary key, not an auto-increment id
$this->addType('id', 'integer');
$this->addType('userId', 'string');
$this->addType('postCount', 'integer');
$this->addType('threadCount', 'integer');

View File

@@ -8,27 +8,47 @@ declare(strict_types=1);
namespace OCA\Forum\Listener;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\StatsService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\User\Events\UserCreatedEvent;
use OCP\User\Events\UserDeletedEvent;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<UserDeletedEvent>
* @template-implements IEventListener<UserCreatedEvent|UserDeletedEvent>
*/
class UserEventListener implements IEventListener {
public function __construct(
private UserStatsMapper $userStatsMapper,
private StatsService $statsService,
private LoggerInterface $logger,
) {
}
public function handle(Event $event): void {
if ($event instanceof UserDeletedEvent) {
if ($event instanceof UserCreatedEvent) {
$this->handleUserCreated($event);
} elseif ($event instanceof UserDeletedEvent) {
$this->handleUserDeleted($event);
}
}
private function handleUserCreated(UserCreatedEvent $event): void {
$user = $event->getUser();
$userId = $user->getUID();
try {
// Create user stats with zero counts for new user
$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}", [
'exception' => $ex->getMessage(),
]);
}
}
private function handleUserDeleted(UserDeletedEvent $event): void {
$user = $event->getUser();
$userId = $user->getUID();

View File

@@ -563,16 +563,14 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
return;
}
// Find first admin user (fallback to 'admin' if no admin group members found)
// Find first admin user (fallback to 'admin' if no admin users found)
$adminUserId = 'admin';
$adminGroup = $groupManager->get('admin');
if ($adminGroup) {
$adminUsers = $adminGroup->getUsers();
if (!empty($adminUsers)) {
$firstAdmin = reset($adminUsers);
$adminUserId = $firstAdmin->getUID();
$userManager->callForSeenUsers(function ($user) use ($groupManager, &$adminUserId) {
if ($groupManager->isAdmin($user->getUID())) {
$adminUserId = $user->getUID();
return false; // Stop iteration after finding first admin
}
}
});
// Create default roles
$qb = $db->getQueryBuilder();
@@ -651,7 +649,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'name' => $qb->createNamedParameter('Support'),
'description' => $qb->createNamedParameter('Ask questions about the forum, provide feedback or report issues.'),
'slug' => $qb->createNamedParameter('support'),
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'sort_order' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
@@ -753,9 +751,9 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
}
// Assign roles to all Nextcloud users
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $userRoleId, $adminRoleId, $adminGroup) {
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $userRoleId, $adminRoleId, $groupManager) {
$userId = $user->getUID();
$isAdmin = $adminGroup && $adminGroup->inGroup($user);
$isAdmin = $groupManager->isAdmin($userId);
// Assign User role to all users
$qb = $db->getQueryBuilder();

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Migration;
use Closure;
use OCA\Forum\Service\StatsService;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version2Date20251114222614 extends SimpleMigrationStep {
public function __construct(
private StatsService $statsService,
) {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$this->createForumThreadSubsTable($schema);
$this->fixForumUserStatsTable($schema);
return $schema;
}
private function createForumThreadSubsTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('forum_thread_subs')) {
return;
}
$table = $schema->createTable('forum_thread_subs');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('user_id', 'string', [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('thread_id', 'bigint', [
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('created_at', 'integer', [
'notnull' => true,
'unsigned' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['user_id'], 'thread_subs_uid_idx');
$table->addIndex(['thread_id'], 'thread_subs_tid_idx');
$table->addUniqueIndex(['user_id', 'thread_id'], 'thread_subs_uniq_idx');
}
private function fixForumUserStatsTable(ISchemaWrapper $schema): void {
if (!$schema->hasTable('forum_user_stats')) {
return;
}
$table = $schema->getTable('forum_user_stats');
// Check if already fixed (has id column)
if ($table->hasColumn('id')) {
return;
}
// Add id column as auto-increment
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
// Drop the old primary key on user_id
$table->dropPrimaryKey();
// Set id as the new primary key
$table->setPrimaryKey(['id']);
// Add unique index on user_id (since it's no longer the primary key)
if (!$table->hasIndex('user_stats_user_id_uniq')) {
$table->addUniqueIndex(['user_id'], 'user_stats_user_id_uniq');
}
// Add thread_count index
if (!$table->hasIndex('user_stats_thread_count_idx')) {
$table->addIndex(['thread_count'], 'user_stats_thread_count_idx');
}
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$output->info('Creating user statistics for all users...');
$result = $this->statsService->rebuildAllUserStats();
$output->info(sprintf('Processed %d users', $result['users']));
$output->info(sprintf('Created %d new user stats', $result['created']));
$output->info(sprintf('Updated %d existing user stats', $result['updated']));
$output->info('User statistics created successfully!');
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version3Date20251119193455 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
// TODO add migration logic
return $schema;
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
// Fix admin role assignments for existing installations
$this->fixAdminRoleAssignments();
}
/**
* Fix admin role assignments for users who ran the migration before the isAdmin() fix
*/
private function fixAdminRoleAssignments(): void {
$db = \OC::$server->get(\OCP\IDBConnection::class);
$userManager = \OC::$server->get(\OCP\IUserManager::class);
$groupManager = \OC::$server->get(\OCP\IGroupManager::class);
$timestamp = time();
// Get the Admin role ID
$qb = $db->getQueryBuilder();
$qb->select('id')
->from('forum_roles')
->where($qb->expr()->eq('name', $qb->createNamedParameter('Admin')));
$result = $qb->executeQuery();
$adminRole = $result->fetch();
$result->closeCursor();
if (!$adminRole) {
// Admin role doesn't exist, nothing to fix
return;
}
$adminRoleId = $adminRole['id'];
// Check if there are any users already assigned to the Admin role
$qb = $db->getQueryBuilder();
$qb->select('id')
->from('forum_user_roles')
->where($qb->expr()->eq('role_id', $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->setMaxResults(1);
$result = $qb->executeQuery();
$hasAdmins = $result->fetch();
$result->closeCursor();
if ($hasAdmins) {
// Admin roles are already assigned, nothing to fix
return;
}
// No admins found - assign Admin role to all Nextcloud admins
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $adminRoleId, $groupManager) {
$userId = $user->getUID();
$isAdmin = $groupManager->isAdmin($userId);
if ($isAdmin) {
// Check if this user already has the admin role (shouldn't happen, but be safe)
$qb = $db->getQueryBuilder();
$qb->select('id')
->from('forum_user_roles')
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
->andWhere($qb->expr()->eq('role_id', $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
$result = $qb->executeQuery();
$exists = $result->fetch();
$result->closeCursor();
if (!$exists) {
// Assign Admin role
$qb = $db->getQueryBuilder();
$qb->insert('forum_user_roles')
->values([
'user_id' => $qb->createNamedParameter($userId),
'role_id' => $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
}
}
});
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Notification;
use OCA\Forum\AppInfo\Application;
use OCP\L10N\IFactory;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
use OCP\Notification\UnknownNotificationException;
class Notifier implements INotifier {
public function __construct(
private IFactory $l10nFactory,
) {
}
/**
* Identifier of the notifier, only use [a-z0-9_]
*/
public function getID(): string {
return Application::APP_ID;
}
/**
* Human-readable name describing the notifier
*/
public function getName(): string {
return $this->l10nFactory->get(Application::APP_ID)->t('Forum');
}
/**
* Prepare the notification for display
*
* @param INotification $notification
* @param string $languageCode The code of the language that should be used to prepare the notification
* @return INotification
* @throws \InvalidArgumentException When the notification was not prepared by this app or is not of the expected type
*/
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== Application::APP_ID) {
throw new UnknownNotificationException();
}
$l = $this->l10nFactory->get(Application::APP_ID, $languageCode);
switch ($notification->getSubject()) {
case 'new_posts':
$parameters = $notification->getSubjectParameters();
$threadId = $parameters['threadId'] ?? 0;
$threadTitle = $parameters['threadTitle'] ?? 'Unknown Thread';
$postCount = $parameters['postCount'] ?? 1;
// Set the rich subject with thread title
$notification->setRichSubject(
$l->n(
'New reply in {thread}',
'{count} new replies in {thread}',
$postCount
),
[
'thread' => [
'type' => 'highlight',
'id' => (string)$threadId,
'name' => $threadTitle,
],
'count' => [
'type' => 'highlight',
'id' => (string)$postCount,
'name' => (string)$postCount,
],
]
);
// Set the parsed subject from rich subject
$this->setParsedSubjectFromRichSubject($notification);
return $notification;
default:
throw new UnknownNotificationException();
}
}
/**
* Helper function to set the parsed subject from the rich subject
* This extracts the parameter names from rich subject placeholders
*
* @param INotification $notification
*/
protected function setParsedSubjectFromRichSubject(INotification $notification): void {
$placeholders = $replacements = [];
$richParams = $notification->getRichSubjectParameters();
$richSubject = $notification->getRichSubject();
foreach ($richParams as $placeholder => $parameter) {
$placeholders[] = '{' . $placeholder . '}';
if (isset($parameter['type']) && $parameter['type'] === 'file') {
$replacements[] = $parameter['path'] ?? $parameter['name'] ?? '';
} else {
$replacements[] = $parameter['name'] ?? '';
}
}
$parsedSubject = str_replace($placeholders, $replacements, $richSubject);
$notification->setParsedSubject($parsedSubject);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IURLGenerator;
use OCP\Notification\IManager as INotificationManager;
use Psr\Log\LoggerInterface;
class NotificationService {
public function __construct(
private INotificationManager $notificationManager,
private ThreadSubscriptionMapper $subscriptionMapper,
private ThreadMapper $threadMapper,
private PostMapper $postMapper,
private ReadMarkerMapper $readMarkerMapper,
private IURLGenerator $urlGenerator,
private LoggerInterface $logger,
) {
}
/**
* Notify subscribed users when a new post is added to a thread
*/
public function notifyThreadSubscribers(int $threadId, int $postId, string $authorId): void {
// Get all subscribed users for this thread
$subscriptions = $this->subscriptionMapper->findByThread($threadId);
// Get thread information
try {
$thread = $this->threadMapper->find($threadId);
} catch (\Exception $e) {
$this->logger->warning('Thread not found for notifications', [
'threadId' => $threadId,
'error' => $e->getMessage(),
]);
return;
}
foreach ($subscriptions as $subscription) {
$userId = $subscription->getUserId();
// Don't notify the author of the post
if ($userId === $authorId) {
continue;
}
// Create or update notification (collating multiple posts)
$this->createOrUpdateNotification($userId, $threadId, $postId, $thread->getTitle(), $thread->getSlug());
}
}
/**
* Create or update a notification for a user about a thread
* This allows collating multiple posts into a single notification
*/
private function createOrUpdateNotification(string $userId, int $threadId, int $postId, string $threadTitle, string $threadSlug): void {
// Calculate the number of unread posts
$postCount = $this->getUnreadPostCount($userId, $threadId, $postId);
// Mark existing notifications for this thread/user as processed (to update them)
$existingNotification = $this->notificationManager->createNotification();
$existingNotification->setApp('forum')
->setUser($userId)
->setObject('thread', (string)$threadId)
->setSubject('new_posts');
$this->notificationManager->markProcessed($existingNotification);
// Create new notification with updated post count
$notification = $this->notificationManager->createNotification();
// Generate the thread link and icon
$threadLink = $this->urlGenerator->linkToRouteAbsolute('forum.page.index') . 't/' . $threadSlug;
$iconPath = $this->urlGenerator->imagePath('forum', 'app-dark.svg');
$iconUrl = $this->urlGenerator->getAbsoluteURL($iconPath);
$notification->setApp('forum')
->setUser($userId)
->setDateTime(new \DateTime())
->setObject('thread', (string)$threadId)
->setSubject('new_posts', [
'threadId' => $threadId,
'threadTitle' => $threadTitle,
'threadSlug' => $threadSlug,
'lastPostId' => $postId,
'postCount' => $postCount,
])
->setLink($threadLink)
->setIcon($iconUrl);
$this->notificationManager->notify($notification);
}
/**
* Get the count of unread posts for a user in a thread
* Uses an efficient DB COUNT query instead of fetching all posts
*/
private function getUnreadPostCount(string $userId, int $threadId, int $latestPostId): int {
try {
// Get the user's read marker for this thread
$readMarker = $this->readMarkerMapper->findByUserAndThread($userId, $threadId);
$lastReadPostId = $readMarker->getLastReadPostId();
// Count posts after the last read post using DB query
$unreadCount = $this->postMapper->countUnreadInThread($threadId, $lastReadPostId);
return max(1, $unreadCount); // At least 1 (the current post)
} catch (DoesNotExistException $e) {
// No read marker, count all posts in the thread
$count = $this->postMapper->countUnreadInThread($threadId, 0);
return max(1, $count); // At least 1
}
}
/**
* Dismiss notifications for a user when they view a thread
*/
public function dismissThreadNotifications(string $userId, int $threadId): void {
$notification = $this->notificationManager->createNotification();
$notification->setApp('forum')
->setUser($userId)
->setObject('thread', (string)$threadId);
$this->notificationManager->markProcessed($notification);
}
/**
* Dismiss notifications when read marker catches up
*/
public function dismissNotificationsIfRead(string $userId, int $threadId, int $lastReadPostId): void {
// Get the thread to check the last post
try {
$thread = $this->threadMapper->find($threadId);
$lastPostId = $thread->getLastPostId();
// If user has read up to or past the last post, dismiss notifications
if ($lastPostId && $lastReadPostId >= $lastPostId) {
$this->dismissThreadNotifications($userId, $threadId);
}
} catch (\Exception $e) {
// Thread not found or error, just dismiss anyway
$this->dismissThreadNotifications($userId, $threadId);
}
}
}

View File

@@ -28,6 +28,31 @@ class PermissionService {
) {
}
/**
* Check if user has Admin or Moderator role
*
* @param string $userId Nextcloud user ID
* @return bool True if user has Admin (roleId 1) or Moderator (roleId 2) role
*/
public function hasAdminOrModeratorRole(string $userId): bool {
try {
$userRoles = $this->userRoleMapper->findByUserId($userId);
foreach ($userRoles as $userRole) {
$roleId = $userRole->getRoleId();
// Admin role = 1, Moderator role = 2
if ($roleId === 1 || $roleId === 2) {
return true;
}
}
return false;
} catch (\Exception $e) {
$this->logger->error("Error checking admin/moderator role for user $userId: " . $e->getMessage());
return false;
}
}
/**
* Check if user has global permission
*

View File

@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
use OCP\IDBConnection;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
class StatsService {
public function __construct(
private IDBConnection $db,
private IUserManager $userManager,
private LoggerInterface $logger,
) {
}
/**
* Create user statistics for all users in the system (including those who haven't posted)
*
* @return array{users: int, updated: int, created: int} Statistics about the creation
*/
public function rebuildAllUserStats(): array {
// Get all user IDs from Nextcloud
$users = [];
$this->userManager->callForAllUsers(function ($user) use (&$users) {
$users[] = $user->getUID();
});
$updated = 0;
$created = 0;
foreach ($users as $userId) {
$wasCreated = $this->rebuildUserStats($userId);
if ($wasCreated) {
$created++;
} else {
$updated++;
}
}
return [
'users' => count($users),
'updated' => $updated,
'created' => $created,
];
}
/**
* Rebuild statistics for a single user
*
* @param string $userId The user ID to rebuild stats for
* @return bool True if stats were created, false if they were updated
*/
public function rebuildUserStats(string $userId): bool {
// Count non-deleted threads created by this user
$threadQb = $this->db->getQueryBuilder();
$threadQb->select($threadQb->func()->count('*', 'count'))
->from('forum_threads')
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)))
->andWhere($threadQb->expr()->isNull('deleted_at'));
$threadResult = $threadQb->executeQuery();
$threadCount = (int)($threadResult->fetchOne() ?? 0);
$threadResult->closeCursor();
// Count non-deleted posts created by this user (from non-deleted threads)
// Exclude is_first_post posts as they are counted as threads
$postQb = $this->db->getQueryBuilder();
$postQb->select($postQb->func()->count('*', 'count'))
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
->where($postQb->expr()->eq('p.author_id', $postQb->createNamedParameter($userId)))
->andWhere($postQb->expr()->isNull('p.deleted_at'))
->andWhere($postQb->expr()->isNull('t.deleted_at'))
->andWhere($postQb->expr()->eq('p.is_first_post', $postQb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)));
$postResult = $postQb->executeQuery();
$postCount = (int)($postResult->fetchOne() ?? 0);
$postResult->closeCursor();
// Get the timestamp of the last non-deleted post (from non-deleted threads)
$lastPostQb = $this->db->getQueryBuilder();
$lastPostQb->select('p.created_at')
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $lastPostQb->expr()->eq('p.thread_id', 't.id'))
->where($lastPostQb->expr()->eq('p.author_id', $lastPostQb->createNamedParameter($userId)))
->andWhere($lastPostQb->expr()->isNull('p.deleted_at'))
->andWhere($lastPostQb->expr()->isNull('t.deleted_at'))
->orderBy('p.created_at', 'DESC')
->setMaxResults(1);
$lastPostResult = $lastPostQb->executeQuery();
$lastPostAt = $lastPostResult->fetchOne();
$lastPostResult->closeCursor();
// Check if user stats already exist
$checkQb = $this->db->getQueryBuilder();
$checkQb->select('id')
->from('forum_user_stats')
->where($checkQb->expr()->eq('user_id', $checkQb->createNamedParameter($userId)));
$checkResult = $checkQb->executeQuery();
$exists = $checkResult->fetch();
$checkResult->closeCursor();
$timestamp = time();
if ($exists) {
// Update existing stats
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats')
->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($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->where($updateQb->expr()->eq('user_id', $updateQb->createNamedParameter($userId)));
if ($lastPostAt) {
$updateQb->set('last_post_at', $updateQb->createNamedParameter((int)$lastPostAt, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT));
}
$updateQb->executeStatement();
return false;
} else {
// Create new stats
$insertQb = $this->db->getQueryBuilder();
$insertQb->insert('forum_user_stats')
->values([
'user_id' => $insertQb->createNamedParameter($userId),
'thread_count' => $insertQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $insertQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'last_post_at' => $insertQb->createNamedParameter($lastPostAt ? (int)$lastPostAt : null, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $insertQb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $insertQb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
]);
try {
$insertQb->executeStatement();
return true;
} catch (\Exception $e) {
// If insert fails (race condition), try updating instead
$this->logger->warning('Failed to create user stats, attempting update', [
'userId' => $userId,
'exception' => $e->getMessage(),
]);
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats')
->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($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->where($updateQb->expr()->eq('user_id', $updateQb->createNamedParameter($userId)));
if ($lastPostAt) {
$updateQb->set('last_post_at', $updateQb->createNamedParameter((int)$lastPostAt, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT));
}
$updateQb->executeStatement();
return false;
}
}
}
/**
* 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

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
use OCA\Forum\AppInfo\Application;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
class UserPreferencesService {
/** Preference key for auto-subscribing to created threads */
public const PREF_AUTO_SUBSCRIBE_CREATED_THREADS = 'auto_subscribe_created_threads';
/** @var array<string, mixed> Default preference values */
private const DEFAULTS = [
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => true,
];
/** @var array<string> List of valid preference keys */
private const VALID_KEYS = [
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS,
];
public function __construct(
private IConfig $config,
private LoggerInterface $logger,
) {
}
/**
* Get all user preferences
*
* @param string $userId The user ID
* @return array<string, mixed> All user preferences
*/
public function getAllPreferences(string $userId): array {
$preferences = [];
foreach (self::VALID_KEYS as $key) {
$preferences[$key] = $this->getPreference($userId, $key);
}
return $preferences;
}
/**
* Get a single user preference
*
* @param string $userId The user ID
* @param string $key The preference key
* @return mixed The preference value
* @throws \InvalidArgumentException If the preference key is invalid
*/
public function getPreference(string $userId, string $key): mixed {
if (!in_array($key, self::VALID_KEYS, true)) {
throw new \InvalidArgumentException("Invalid preference key: $key");
}
$default = self::DEFAULTS[$key] ?? null;
$value = $this->config->getUserValue($userId, Application::APP_ID, $key, $default);
return $this->parseValue($value);
}
/**
* Update multiple user preferences
*
* @param string $userId The user ID
* @param array<string, mixed> $preferences Key-value pairs of preferences to update
* @return array<string, mixed> All user preferences after update
* @throws \InvalidArgumentException If any preference key is invalid
*/
public function updatePreferences(string $userId, array $preferences): array {
// Validate all keys before updating
foreach ($preferences as $key => $value) {
if (!in_array($key, self::VALID_KEYS, true)) {
throw new \InvalidArgumentException("Invalid preference key: $key");
}
}
// Update each preference
foreach ($preferences as $key => $value) {
$this->setPreference($userId, $key, $value);
}
// Return all preferences after update
return $this->getAllPreferences($userId);
}
/**
* Set a single user preference
*
* @param string $userId The user ID
* @param string $key The preference key
* @param mixed $value The preference value
* @throws \InvalidArgumentException If the preference key is invalid
*/
public function setPreference(string $userId, string $key, mixed $value): void {
if (!in_array($key, self::VALID_KEYS, true)) {
throw new \InvalidArgumentException("Invalid preference key: $key");
}
$stringValue = $this->stringifyValue($value);
$this->config->setUserValue($userId, Application::APP_ID, $key, $stringValue);
}
/**
* Parse a string value back to its proper type
*
* @param mixed $value The value to parse
* @return mixed The parsed value
*/
private function parseValue(mixed $value): mixed {
if ($value === 'true') {
return true;
}
if ($value === 'false') {
return false;
}
if (is_numeric($value)) {
return strpos($value, '.') !== false ? (float)$value : (int)$value;
}
return $value;
}
/**
* Convert a value to string for storage
*
* @param mixed $value The value to stringify
* @return string The stringified value
*/
private function stringifyValue(mixed $value): string {
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
return (string)$value;
}
}

View File

@@ -6630,6 +6630,108 @@
}
}
},
"/ocs/v2.php/apps/forum/api/settings": {
"get": {
"operationId": "settings-get-public-settings",
"summary": "Get public forum settings (title and subtitle)",
"description": "This endpoint is publicly accessible to all users. For admin-only settings, use AdminController::getSettings()",
"tags": [
"settings"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Settings retrieved successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"title",
"subtitle"
],
"properties": {
"title": {
"type": "string"
},
"subtitle": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/threads": {
"get": {
"operationId": "thread-index",
@@ -7802,6 +7904,715 @@
}
}
},
"/ocs/v2.php/apps/forum/api/threads/{threadId}/subscribe": {
"post": {
"operationId": "thread_subscription-subscribe",
"summary": "Subscribe current user to a thread to receive notifications",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User subscribed to thread",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "thread_subscription-unsubscribe",
"summary": "Unsubscribe current user from a thread",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User unsubscribed from thread",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"get": {
"operationId": "thread_subscription-is-subscribed",
"summary": "Check if current user is subscribed to a thread",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Subscription status returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/thread-subscriptions": {
"get": {
"operationId": "thread_subscription-get-user-subscriptions",
"summary": "Get all threads the current user is subscribed to",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Thread subscriptions returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/user-preferences": {
"get": {
"operationId": "user_preferences-index",
"summary": "Get all user preferences",
"tags": [
"user_preferences"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Preferences returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "User not authenticated",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
},
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
]
}
}
}
}
}
},
"put": {
"operationId": "user_preferences-update",
"summary": "Update user preferences",
"tags": [
"user_preferences"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"preferences"
],
"properties": {
"preferences": {
"type": "object",
"description": "Key-value pairs of preferences to update",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Preferences updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "User not authenticated",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
},
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
]
}
}
}
},
"400": {
"description": "Invalid preference key or value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/users/{userId}/roles": {
"get": {
"operationId": "user_role-by-user",

View File

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

@@ -103,6 +103,10 @@ export default defineComponent({
padding: 1rem;
min-height: 0;
scroll-behavior: smooth;
@media (max-width: 768px) {
padding: 0;
}
}
.bottom-spacer {
@@ -115,6 +119,7 @@ export default defineComponent({
align-items: center;
justify-content: center;
height: 100%;
margin-top: 128px;
}
</style>

View File

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

@@ -10,7 +10,7 @@
<NcAppNavigationItem
:name="strings.navSearch"
:to="{ path: '/search' }"
:active="isSearchActive"
:active="isPathActive('/search')"
>
<template #icon>
<MagnifyIcon :size="20" />
@@ -55,6 +55,17 @@
</NcAppNavigationItem>
</template>
</NcAppNavigationItem>
<!-- Preferences menu item -->
<NcAppNavigationItem
:name="strings.navPreferences"
:to="{ path: '/preferences' }"
:active="isPathActive('/preferences')"
>
<template #icon>
<AccountCogIcon :size="20" />
</template>
</NcAppNavigationItem>
</NcAppNavigationItem>
<!-- Admin menu item - only visible to admins -->
@@ -80,7 +91,7 @@
<NcAppNavigationItem
:name="strings.navAdminDashboard"
:to="{ path: '/admin' }"
:active="isAdminDashboardActive"
:active="isPathActive('/admin')"
>
<template #icon>
<ChartLineIcon :size="20" />
@@ -90,7 +101,7 @@
<NcAppNavigationItem
:name="strings.navAdminSettings"
:to="{ path: '/admin/settings' }"
:active="isAdminSettingsActive"
:active="isPathActive('/admin/settings')"
>
<template #icon>
<CogIcon :size="20" />
@@ -100,7 +111,7 @@
<NcAppNavigationItem
:name="strings.navAdminUsers"
:to="{ path: '/admin/users' }"
:active="isAdminUsersActive"
:active="isPathActive('/admin/users', true)"
>
<template #icon>
<AccountMultipleIcon :size="20" />
@@ -110,7 +121,7 @@
<NcAppNavigationItem
:name="strings.navAdminRoles"
:to="{ path: '/admin/roles' }"
:active="isAdminRolesActive"
:active="isPathActive('/admin/roles', true)"
>
<template #icon>
<ShieldAccountIcon :size="20" />
@@ -120,7 +131,7 @@
<NcAppNavigationItem
:name="strings.navAdminCategories"
:to="{ path: '/admin/categories' }"
:active="isAdminCategoriesActive"
:active="isPathActive('/admin/categories', true)"
>
<template #icon>
<FolderIcon :size="20" />
@@ -130,7 +141,7 @@
<NcAppNavigationItem
:name="strings.navAdminBBCodes"
:to="{ path: '/admin/bbcodes' }"
:active="isAdminBBCodesActive"
:active="isPathActive('/admin/bbcodes', true)"
>
<template #icon>
<CodeBracketsIcon :size="20" />
@@ -142,8 +153,7 @@
<template #footer>
<div v-if="userId" class="sidebar-footer">
<NcAvatar :user="userId" :size="32" />
<span class="user-display-name">{{ displayName }}</span>
<UserInfo :user-id="userId" :display-name="displayName" :avatar-size="32" />
</div>
</template>
</NcAppNavigation>
@@ -156,7 +166,7 @@ import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import UserInfo from '@/components/UserInfo.vue'
import HomeIcon from '@icons/Home.vue'
import ForumIcon from '@icons/Forum.vue'
import FolderIcon from '@icons/Folder.vue'
@@ -169,6 +179,7 @@ import ChartLineIcon from '@icons/ChartLine.vue'
import AccountMultipleIcon from '@icons/AccountMultiple.vue'
import CodeBracketsIcon from '@icons/CodeBrackets.vue'
import CogIcon from '@icons/Cog.vue'
import AccountCogIcon from '@icons/AccountCog.vue'
import { useCategories } from '@/composables/useCategories'
import { useCurrentUser } from '@/composables/useCurrentUser'
import { useUserRole } from '@/composables/useUserRole'
@@ -182,7 +193,7 @@ export default defineComponent({
NcAppNavigationItem,
NcAppNavigationSearch,
NcActionButton,
NcAvatar,
UserInfo,
HomeIcon,
ForumIcon,
FolderIcon,
@@ -195,6 +206,7 @@ export default defineComponent({
AccountMultipleIcon,
CodeBracketsIcon,
CogIcon,
AccountCogIcon,
},
setup() {
const { categoryHeaders, fetchCategories } = useCategories()
@@ -225,13 +237,15 @@ export default defineComponent({
searchValue: '',
openHeaders: {} as Record<number, boolean>,
isAdminOpen: true,
STORAGE_KEY: 'forum_navigation_state',
strings: {
searchLabel: t('forum', 'Search'),
navHome: t('forum', 'Home'),
navSearch: t('forum', 'Search'),
navPreferences: t('forum', 'User Preferences'),
navAdmin: t('forum', 'Admin'),
navAdminDashboard: t('forum', 'Dashboard'),
navAdminSettings: t('forum', 'Settings'),
navAdminSettings: t('forum', 'Forum Settings'),
navAdminUsers: t('forum', 'Users'),
navAdminRoles: t('forum', 'Roles'),
navAdminCategories: t('forum', 'Categories'),
@@ -241,50 +255,80 @@ export default defineComponent({
},
}
},
computed: {
isSearchActive(): boolean {
return this.$route.path === '/search'
},
isAdminDashboardActive(): boolean {
return this.$route.path === '/admin'
},
isAdminSettingsActive(): boolean {
return this.$route.path === '/admin/settings'
},
isAdminUsersActive(): boolean {
return this.$route.path.startsWith('/admin/users')
},
isAdminRolesActive(): boolean {
return this.$route.path.startsWith('/admin/roles')
},
isAdminCategoriesActive(): boolean {
return this.$route.path.startsWith('/admin/categories')
},
isAdminBBCodesActive(): boolean {
return this.$route.path.startsWith('/admin/bbcodes')
},
},
async created() {
// Fetch categories for sidebar
try {
await this.fetchCategories()
// Initialize all headers as open by default
const openState: Record<number, boolean> = {}
this.categoryHeaders.forEach((header) => {
openState[header.id] = true
})
this.openHeaders = openState
// Load saved state from local storage
this.loadNavigationState()
} catch (e) {
console.error('Failed to load categories for sidebar:', e)
}
},
methods: {
loadNavigationState(): void {
try {
const savedState = localStorage.getItem(this.STORAGE_KEY)
if (savedState) {
const parsed = JSON.parse(savedState)
// Load admin section state
if (typeof parsed.isAdminOpen === 'boolean') {
this.isAdminOpen = parsed.isAdminOpen
}
// Load category headers state
if (parsed.openHeaders && typeof parsed.openHeaders === 'object') {
this.openHeaders = parsed.openHeaders
}
}
// Initialize headers that don't have saved state to open by default
const openState: Record<number, boolean> = { ...this.openHeaders }
this.categoryHeaders.forEach((header) => {
if (openState[header.id] === undefined) {
openState[header.id] = true
}
})
this.openHeaders = openState
} catch (e) {
console.error('Failed to load navigation state from local storage:', e)
// Fallback: Initialize all headers as open by default
const openState: Record<number, boolean> = {}
this.categoryHeaders.forEach((header) => {
openState[header.id] = true
})
this.openHeaders = openState
}
},
saveNavigationState(): void {
try {
const state = {
isAdminOpen: this.isAdminOpen,
openHeaders: this.openHeaders,
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state))
} catch (e) {
console.error('Failed to save navigation state to local storage:', e)
}
},
isPathActive(path: string, usePrefix = false): boolean {
if (usePrefix) {
return this.$route.path.startsWith(path)
}
return this.$route.path === path
},
toggleHeader(headerId: number): void {
this.openHeaders = {
...this.openHeaders,
[headerId]: !this.openHeaders[headerId],
}
this.saveNavigationState()
},
isHeaderOpen(headerId: number): boolean {
@@ -293,6 +337,7 @@ export default defineComponent({
toggleAdmin(): void {
this.isAdminOpen = !this.isAdminOpen
this.saveNavigationState()
},
isCategoryActive(category: Category): boolean {
@@ -352,17 +397,6 @@ export default defineComponent({
<style scoped lang="scss">
.sidebar-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
.user-display-name {
font-weight: 500;
color: var(--color-main-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -22,7 +22,6 @@ export default defineComponent({
.app-toolbar {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
@@ -33,15 +32,22 @@ export default defineComponent({
align-items: center;
gap: 12px;
flex-wrap: wrap;
max-width: 100%;
}
.toolbar-left {
flex: 1;
flex: 1 1 auto;
min-width: 200px;
@media (max-width: 768px) {
padding-left: 32px;
}
}
.toolbar-right {
flex-shrink: 0;
margin-left: auto;
justify-content: flex-end;
}
}
</style>

View File

@@ -11,18 +11,24 @@
class="bbcode-editor-textarea"
ref="textarea"
/>
<NcNoteCard v-if="hasAttachmentBBCode" type="warning" class="attachment-disclaimer">
<span v-html="strings.attachmentDisclaimer"></span>
</NcNoteCard>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import BBCodeToolbar from './BBCodeToolbar.vue'
import { t } from '@nextcloud/l10n'
export default defineComponent({
name: 'BBCodeEditor',
components: {
NcTextArea,
NcNoteCard,
BBCodeToolbar,
},
props: {
@@ -51,8 +57,21 @@ export default defineComponent({
data() {
return {
textareaElement: null as HTMLTextAreaElement | null,
strings: {
attachmentDisclaimer: t(
'forum',
"{bStart}Please note:{bEnd} Attached files will be visible to anyone in the forum, regardless of the file's sharing settings.",
{ bStart: '<strong>', bEnd: '</strong>' },
{ escape: false },
),
},
}
},
computed: {
hasAttachmentBBCode(): boolean {
return /\[attachment[^\]]*\]/i.test(this.modelValue)
},
},
mounted() {
this.updateTextareaRef()
},
@@ -102,4 +121,8 @@ export default defineComponent({
height: unset !important;
}
}
.attachment-disclaimer {
margin-top: 8px;
}
</style>

View File

@@ -14,12 +14,25 @@
</template>
</NcButton>
<LazyEmojiPicker @select="handleEmojiSelect">
<NcButton
variant="tertiary"
:aria-label="strings.emojiLabel"
:title="strings.emojiLabel"
class="bbcode-button"
>
<template #icon>
<EmoticonIcon :size="20" />
</template>
</NcButton>
</LazyEmojiPicker>
<div class="toolbar-spacer"></div>
<NcButton
variant="tertiary"
:aria-label="helpLabel"
:title="helpLabel"
:aria-label="strings.helpLabel"
:title="strings.helpLabel"
@click="showHelp = true"
class="bbcode-button bbcode-help-button"
>
@@ -36,6 +49,7 @@
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import LazyEmojiPicker from '@/components/LazyEmojiPicker'
import { getFilePickerBuilder } from '@nextcloud/dialogs'
import FormatBoldIcon from '@icons/FormatBold.vue'
import FormatItalicIcon from '@icons/FormatItalic.vue'
@@ -56,6 +70,7 @@ import FormatAlignRightIcon from '@icons/FormatAlignRight.vue'
import EyeOffIcon from '@icons/EyeOff.vue'
import FormatListBulletedIcon from '@icons/FormatListBulleted.vue'
import PaperclipIcon from '@icons/Paperclip.vue'
import EmoticonIcon from '@icons/Emoticon.vue'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
import { t } from '@nextcloud/l10n'
@@ -76,7 +91,9 @@ export default defineComponent({
name: 'BBCodeToolbar',
components: {
NcButton,
LazyEmojiPicker,
BBCodeHelpDialog,
EmoticonIcon,
HelpCircleIcon,
},
props: {
@@ -89,12 +106,13 @@ export default defineComponent({
data() {
return {
showHelp: false,
strings: {
helpLabel: t('forum', 'BBCode Help'),
emojiLabel: t('forum', 'Insert emoji'),
},
}
},
computed: {
helpLabel(): string {
return t('forum', 'BBCode Help')
},
bbcodeButtons(): BBCodeButton[] {
return [
{
@@ -366,6 +384,34 @@ export default defineComponent({
// Otherwise, user simply canceled - no need to log
}
},
handleEmojiSelect(emoji: string): void {
if (!this.textareaRef) {
return
}
const textarea = this.textareaRef
const start = textarea.selectionStart
const end = textarea.selectionEnd
const beforeText = textarea.value.substring(0, start)
const afterText = textarea.value.substring(end)
const newText = beforeText + emoji + afterText
const cursorPos = beforeText.length + emoji.length
// Emit the insert event so the parent can update the model
this.$emit('insert', {
text: newText,
cursorPos,
selectedText: '',
})
// Focus the textarea after insertion
this.$nextTick(() => {
textarea.focus()
textarea.setSelectionRange(cursorPos, cursorPos)
})
},
},
})
</script>

View File

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

View File

@@ -0,0 +1,75 @@
<template>
<div class="page-header">
<template v-if="loading">
<Skeleton width="200px" height="1lh" radius="6px" />
<Skeleton width="350px" height="1lh" radius="4px" class="mt-8" />
</template>
<template v-else>
<h2 class="page-title">{{ title }}</h2>
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Skeleton from './Skeleton.vue'
export default defineComponent({
name: 'PageHeader',
components: {
Skeleton,
},
props: {
/**
* The main title/heading
*/
title: {
type: String,
default: '',
},
/**
* Optional subtitle/description
*/
subtitle: {
type: String,
default: '',
},
/**
* Show loading skeleton
*/
loading: {
type: Boolean,
default: false,
},
},
})
</script>
<style scoped lang="scss">
.page-header {
padding: 20px;
background: var(--color-background-hover);
border-radius: 8px;
border: 1px solid var(--color-border);
margin-bottom: 16px;
.mt-8 {
margin-top: 8px;
}
}
.page-title {
margin: 0 0 8px 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-main-text);
}
.page-subtitle {
margin: 0;
font-size: 1rem;
color: var(--color-text-lighter);
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="page-wrapper-container">
<!-- Toolbar slot - always full width -->
<div v-if="$slots.toolbar" class="page-wrapper-toolbar">
<slot name="toolbar" />
</div>
<!-- Content wrapper - respects fullWidth prop -->
<div class="page-wrapper-content" :class="{ 'full-width': fullWidth }">
<slot />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'PageWrapper',
props: {
/**
* Whether to use full width or fixed width (900px max with auto margins)
*/
fullWidth: {
type: Boolean,
default: false,
},
},
})
</script>
<style scoped lang="scss">
.page-wrapper-container {
display: flex;
flex-direction: column;
@media screen and (max-width: 768px) {
padding: 0;
}
}
.page-wrapper-toolbar {
width: 100%;
flex-shrink: 0;
}
.page-wrapper-content {
padding: 16px;
max-width: 900px;
margin: 0 auto;
width: 100%;
&.full-width {
max-width: none;
margin: 0;
}
}
</style>

View File

@@ -2,30 +2,23 @@
<div class="post-card" :class="{ 'first-post': isFirstPost, unread: isUnread }">
<div class="post-header">
<div class="author-info">
<div v-if="!post.authorIsDeleted" class="avatar-link" @click.stop="navigateToProfile">
<NcAvatar :user="post.authorId" :size="32" />
</div>
<NcAvatar v-else :display-name="post.authorDisplayName" :size="32" />
<div class="author-details">
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
<span
v-if="!post.authorIsDeleted"
class="author-name author-name-link"
@click.stop="navigateToProfile"
>
{{ post.authorDisplayName || post.authorId }}
</span>
<span v-else class="author-name deleted-user">
{{ post.authorDisplayName || post.authorId }}
</span>
<div class="post-meta">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
<span v-if="post.isEdited" class="edited-badge">
<span class="edited-label">{{ strings.edited }}</span>
<NcDateTime v-if="post.editedAt" :timestamp="post.editedAt * 1000" />
</span>
</div>
</div>
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
<UserInfo
:user-id="post.authorId"
:display-name="post.authorDisplayName || post.authorId"
:is-deleted="post.authorIsDeleted"
:avatar-size="32"
>
<template #meta>
<div class="post-meta">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
<span v-if="post.isEdited" class="edited-badge">
<span class="edited-label">{{ strings.edited }}</span>
<NcDateTime v-if="post.editedAt" :timestamp="post.editedAt * 1000" />
</span>
</div>
</template>
</UserInfo>
</div>
<div class="post-actions">
<NcActions ref="actionsMenu">
@@ -77,30 +70,31 @@
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import ReplyIcon from '@icons/Reply.vue'
import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
import UserInfo from './UserInfo.vue'
import PostReactions from './PostReactions.vue'
import PostEditForm from './PostEditForm.vue'
import { t } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
import { useUserRole } from '@/composables/useUserRole'
import type { Post } from '@/types'
import type { ReactionGroup } from '@/composables/useReactions'
export default defineComponent({
name: 'PostCard',
components: {
NcAvatar,
NcDateTime,
NcActions,
NcActionButton,
ReplyIcon,
PencilIcon,
DeleteIcon,
UserInfo,
PostReactions,
PostEditForm,
},
@@ -119,6 +113,14 @@ export default defineComponent({
},
},
emits: ['reply', 'edit', 'delete', 'update'],
setup() {
const { isAdmin, isModerator } = useUserRole()
return {
isAdmin,
isModerator,
}
},
data() {
return {
isEditing: false,
@@ -140,11 +142,20 @@ export default defineComponent({
return getCurrentUser()
},
canEdit(): boolean {
return this.currentUser !== null && this.currentUser.uid === this.post.authorId
// Authors can edit their own posts
// Admins and moderators can edit any post
if (!this.currentUser) {
return false
}
return this.currentUser.uid === this.post.authorId || this.isAdmin || this.isModerator
},
canDelete(): boolean {
// For now, only author can delete. Later add admin/moderator check
return this.currentUser !== null && this.currentUser.uid === this.post.authorId
// Authors can delete their own posts
// Admins and moderators can delete any post
if (!this.currentUser) {
return false
}
return this.currentUser.uid === this.post.authorId || this.isAdmin || this.isModerator
},
formattedContent(): string {
// Content is already parsed by BBCodeService on the backend
@@ -160,10 +171,6 @@ export default defineComponent({
}
},
navigateToProfile() {
this.$router.push(`/u/${this.post.authorId}`)
},
handleReply() {
this.closeActionsMenu()
this.$emit('reply', this.post)
@@ -274,46 +281,12 @@ export default defineComponent({
align-items: flex-start;
gap: 12px;
flex: 1;
}
.author-details {
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
.unread-indicator {
position: absolute;
left: -14px;
top: 6px;
}
}
.avatar-link {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
.author-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
&.author-name-link {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-primary-element);
}
}
&.deleted-user {
font-style: italic;
opacity: 0.7;
left: 0;
top: 8px;
}
}

View File

@@ -14,43 +14,11 @@
</button>
<!-- Add custom reaction button -->
<div class="add-reaction">
<button
class="add-reaction-button"
:class="{ open: showPicker }"
:title="strings.addReaction"
@click="togglePicker"
>
<LazyEmojiPicker @select="handleSelectEmoji" style="display: inline-block">
<button class="add-reaction-button" :title="strings.addReaction">
<span class="icon">+</span>
</button>
<!-- Emoji picker -->
<Transition name="fade">
<div v-if="showPicker" class="emoji-picker-overlay" @click="closePicker">
<div class="emoji-picker-container" @click.stop>
<div class="emoji-picker-content">
<h3>{{ strings.pickEmoji }}</h3>
<div class="emoji-categories">
<div v-for="group in emojiGroups" :key="group.name" class="emoji-category">
<h4 class="category-header">{{ group.name }}</h4>
<div class="emoji-grid">
<button
v-for="item in group.emojis"
:key="item.emoji"
class="emoji-option"
:title="item.title"
@click="handleSelectEmoji(item.emoji)"
>
{{ item.emoji }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</LazyEmojiPicker>
</div>
</template>
@@ -59,10 +27,13 @@ import { defineComponent, type PropType } from 'vue'
import { t, n } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
import { useReactions, type ReactionGroup } from '@/composables/useReactions'
import { EMOJI_GROUPS } from '@/constants/emojis'
import LazyEmojiPicker from '@/components/LazyEmojiPicker'
export default defineComponent({
name: 'PostReactions',
components: {
LazyEmojiPicker,
},
props: {
postId: {
type: Number,
@@ -82,12 +53,9 @@ export default defineComponent({
return {
defaultEmojis: ['👍', '❤️', '😄', '🎉', '👏'],
reactionGroups: [...this.reactions] as ReactionGroup[],
showPicker: false,
strings: {
addReaction: t('forum', 'Add reaction'),
pickEmoji: t('forum', 'Pick an emoji'),
},
emojiGroups: EMOJI_GROUPS,
}
},
computed: {
@@ -143,25 +111,8 @@ export default defineComponent({
},
},
methods: {
togglePicker() {
this.showPicker = !this.showPicker
},
closePicker() {
this.showPicker = false
},
handleSelectEmoji(emoji: string) {
this.handleToggleReaction(emoji)
this.closePicker()
},
getEmojiTitle(emoji: string): string | null {
// Find the emoji title from the emoji groups
for (const group of this.emojiGroups) {
const item = group.emojis.find((e) => e.emoji === emoji)
if (item) {
return item.title
}
}
return null
},
getCount(emoji: string): number {
const group = this.reactionGroups.find((g) => g.emoji === emoji)
@@ -219,28 +170,27 @@ export default defineComponent({
getReactionTooltip(emoji: string): string {
const count = this.getCount(emoji)
const hasReacted = this.isReacted(emoji)
const title = this.getEmojiTitle(emoji) ?? emoji
if (count === 0) {
return t('forum', 'React with {title}', { title })
return t('forum', 'React with {emoji}', { emoji })
}
if (count === 1) {
return hasReacted
? t('forum', 'You reacted with {title}', { title })
: t('forum', '1 person reacted with {title}', { title })
? t('forum', 'You reacted with {emoji}', { emoji })
: t('forum', '1 person reacted with {emoji}', { emoji })
}
return hasReacted
? n(
'forum',
'You and %n other reacted with {title}',
'You and %n others reacted with {title}',
'You and %n other reacted with {emoji}',
'You and %n others reacted with {emoji}',
count - 1,
{ title },
{ emoji },
)
: n('forum', '%n person reacted with {title}', '%n people reacted with {title}', count, {
title,
: n('forum', '%n person reacted with {emoji}', '%n people reacted with {emoji}', count, {
emoji,
})
},
},
@@ -314,172 +264,41 @@ export default defineComponent({
}
}
.add-reaction {
position: relative;
.add-reaction-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
min-width: 30px;
min-height: 30px;
border: 1px dashed var(--color-border);
background: transparent;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s ease;
opacity: 0.6;
.add-reaction-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
min-width: 30px;
min-height: 30px;
border: 1px dashed var(--color-border);
background: transparent;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s ease;
opacity: 0.6;
&:hover {
opacity: 1;
background: var(--color-background-hover);
border-color: var(--color-border-dark);
border-style: solid;
}
&:active {
transform: scale(0.95);
}
&.open {
opacity: 1;
background: var(--color-background-hover);
border-color: var(--color-primary-element);
border-style: solid;
}
.icon {
font-size: 1.2rem;
line-height: 1;
font-weight: bold;
color: var(--color-text-maxcontrast);
}
&:hover .icon,
&.open .icon {
color: var(--color-main-text);
}
&:hover {
opacity: 1;
background: var(--color-background-hover);
border-color: var(--color-border-dark);
border-style: solid;
}
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
&:active {
transform: scale(0.95);
}
.emoji-picker-container {
background: var(--color-main-background);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
.icon {
font-size: 1.2rem;
line-height: 1;
font-weight: bold;
color: var(--color-text-maxcontrast);
}
.emoji-picker-content {
padding: 20px;
h3 {
margin: 0 0 16px 0;
font-size: 1.1rem;
color: var(--color-main-text);
}
.emoji-categories {
max-height: 500px;
overflow-y: auto;
padding: 4px;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--color-background-dark);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-dark);
border-radius: 4px;
&:hover {
background: var(--color-text-maxcontrast);
}
}
.emoji-category {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.category-header {
margin: 0 0 12px 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
text-transform: uppercase;
letter-spacing: 0.5px;
padding-left: 4px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
.emoji-option {
border: 1px solid transparent;
background: transparent;
border-radius: 8px;
padding: 8px;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 40px;
&:hover {
background: var(--color-background-hover);
border-color: var(--color-border);
transform: scale(1.15);
}
&:active {
transform: scale(0.9);
}
}
}
}
}
}
}
&:hover .icon {
color: var(--color-main-text);
}
}
}
// Transition animations
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,11 +1,12 @@
<template>
<div class="post-reply-form">
<div class="reply-header">
<div class="user-info">
<NcAvatar v-if="userId" :user="userId" :size="40" />
<NcAvatar v-else :display-name="displayName" :size="40" />
<span class="user-name">{{ displayName }}</span>
</div>
<div v-if="userId" class="reply-header">
<UserInfo
:user-id="userId"
:display-name="displayName"
:avatar-size="40"
:clickable="false"
/>
</div>
<div class="reply-body">
@@ -38,10 +39,10 @@
<script lang="ts">
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import SendIcon from '@icons/Send.vue'
import UserInfo from './UserInfo.vue'
import BBCodeEditor from './BBCodeEditor.vue'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
@@ -49,10 +50,10 @@ import { useCurrentUser } from '@/composables/useCurrentUser'
export default defineComponent({
name: 'PostReplyForm',
components: {
NcAvatar,
NcButton,
NcLoadingIcon,
SendIcon,
UserInfo,
BBCodeEditor,
},
emits: ['submit', 'cancel'],
@@ -145,18 +146,6 @@ export default defineComponent({
margin-bottom: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
}
.reply-body {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,99 @@
<template>
<div class="skeleton" :style="skeletonStyle"></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'Skeleton',
props: {
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '20px',
},
shape: {
type: String as () => 'circle' | 'square' | 'rounded-rect',
default: 'rounded-rect',
validator: (value: string) => ['circle', 'square', 'rounded-rect'].includes(value),
},
radius: {
type: String,
default: '4px',
},
},
computed: {
skeletonStyle() {
const borderRadius = this.getBorderRadius()
return {
width: this.width,
height: this.height,
borderRadius,
}
},
},
methods: {
getBorderRadius(): string {
switch (this.shape) {
case 'circle':
return '50%'
case 'square':
return '0'
case 'rounded-rect':
return this.radius
default:
return this.radius
}
},
},
})
</script>
<style scoped lang="scss">
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.skeleton {
position: relative;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.08);
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: shimmer 2s infinite ease-in-out;
}
animation: fadeIn 0.3s ease-in;
}
</style>

View File

@@ -18,23 +18,18 @@
</h4>
</div>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span
v-if="!thread.authorIsDeleted"
class="meta-value meta-value-link"
@click.stop="navigateToProfile"
>
{{ thread.authorDisplayName || thread.authorId }}
</span>
<span v-else class="meta-value deleted-user">
{{ thread.authorDisplayName || thread.authorId }}
</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</span>
<UserInfo
:user-id="thread.authorId"
:display-name="thread.authorDisplayName || thread.authorId"
:is-deleted="thread.authorIsDeleted"
:avatar-size="32"
layout="inline"
@click.stop
>
<template #meta>
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</template>
</UserInfo>
</div>
</div>
@@ -61,6 +56,7 @@
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import UserInfo from '@/components/UserInfo.vue'
import PinIcon from '@icons/Pin.vue'
import LockIcon from '@icons/Lock.vue'
import CommentIcon from '@icons/Comment.vue'
@@ -72,6 +68,7 @@ export default defineComponent({
name: 'ThreadCard',
components: {
NcDateTime,
UserInfo,
PinIcon,
LockIcon,
CommentIcon,
@@ -90,7 +87,6 @@ export default defineComponent({
data() {
return {
strings: {
by: t('forum', 'by'),
replies: t('forum', 'Replies'),
views: t('forum', 'Views'),
pinned: t('forum', 'Pinned thread'),
@@ -99,11 +95,6 @@ export default defineComponent({
},
}
},
methods: {
navigateToProfile() {
this.$router.push(`/u/${this.thread.authorId}`)
},
},
})
</script>
@@ -120,10 +111,11 @@ export default defineComponent({
cursor: inherit;
}
&:hover {
&:hover,
&.unread:hover,
&.pinned:hover {
border-color: var(--color-primary-element);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&.pinned {
@@ -143,8 +135,13 @@ export default defineComponent({
.thread-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
gap: 16px;
@media (max-width: 768px) {
align-items: flex-start;
gap: 6px;
}
}
.unread-indicator {
@@ -205,39 +202,6 @@ export default defineComponent({
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-label {
font-style: italic;
}
.meta-value {
font-weight: 500;
color: var(--color-text-lighter);
&.meta-value-link {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-primary-element);
}
}
&.deleted-user {
font-style: italic;
opacity: 0.7;
}
}
.meta-divider {
opacity: 0.5;
}
.thread-stats {
display: flex;
/* flex-direction: column; */
@@ -253,16 +217,36 @@ export default defineComponent({
padding: 8px;
background: var(--color-background-hover);
border-radius: 6px;
@media (max-width: 768px) {
flex-direction: row;
padding: 6px 8px;
gap: 6px;
}
}
.stat-icon {
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 768px) {
:deep(svg) {
width: 20px;
height: 20px;
}
}
}
.stat-value {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
@media (max-width: 768px) {
font-size: 0.9rem;
}
}
.stat-label {
@@ -270,6 +254,10 @@ export default defineComponent({
color: var(--color-text-maxcontrast);
text-transform: uppercase;
letter-spacing: 0.5px;
@media (max-width: 768px) {
font-size: 0.65rem;
}
}
}
@@ -280,8 +268,10 @@ export default defineComponent({
.thread-stats {
flex-direction: row;
flex-wrap: wrap;
width: 100%;
justify-content: flex-start;
gap: 8px;
}
}
</style>

View File

@@ -1,11 +1,12 @@
<template>
<div class="thread-create-form">
<div class="form-header">
<div class="user-info">
<NcAvatar v-if="userId" :user="userId" :size="40" />
<NcAvatar v-else :display-name="displayName" :size="40" />
<span class="user-name">{{ displayName }}</span>
</div>
<div v-if="userId" class="form-header">
<UserInfo
:user-id="userId"
:display-name="displayName"
:avatar-size="40"
:clickable="false"
/>
</div>
<div class="form-body">
@@ -47,11 +48,11 @@
<script lang="ts">
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import CheckIcon from '@icons/Check.vue'
import UserInfo from './UserInfo.vue'
import BBCodeEditor from './BBCodeEditor.vue'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
@@ -59,11 +60,11 @@ import { useCurrentUser } from '@/composables/useCurrentUser'
export default defineComponent({
name: 'ThreadCreateForm',
components: {
NcAvatar,
NcButton,
NcLoadingIcon,
NcTextField,
CheckIcon,
UserInfo,
BBCodeEditor,
},
emits: ['submit', 'cancel'],
@@ -156,18 +157,6 @@ export default defineComponent({
margin-bottom: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
}
.form-body {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,87 @@
<template>
<div
v-if="!isDeleted"
class="user-avatar"
:style="{ height: size + 'px' }"
:class="{ clickable: isClickable }"
@click="handleClick"
>
<NcAvatar :user="userId" :size="size" />
</div>
<div v-else class="user-avatar">
<NcAvatar :display-name="displayName" :size="size" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
export default defineComponent({
name: 'UserAvatar',
components: {
NcAvatar,
},
props: {
userId: {
type: String,
required: true,
},
displayName: {
type: String,
default: '',
},
size: {
type: Number,
default: 32,
},
isDeleted: {
type: Boolean,
default: false,
},
clickable: {
type: Boolean,
default: true,
},
},
emits: ['click'],
computed: {
isClickable(): boolean {
return this.clickable && !this.isDeleted
},
},
methods: {
handleClick(event: MouseEvent): void {
if (this.isClickable) {
event.stopPropagation()
this.$emit('click', this.userId)
this.$router.push(`/u/${this.userId}`)
}
},
},
})
</script>
<style scoped lang="scss">
.user-avatar {
&.clickable {
cursor: pointer !important;
:deep(.avatardiv) {
cursor: pointer !important;
}
:deep(.avatardiv *) {
cursor: pointer !important;
}
&:hover {
opacity: 0.8;
}
&:hover :deep(.avatardiv) {
opacity: 0.8;
}
}
}
</style>

146
src/components/UserInfo.vue Normal file
View File

@@ -0,0 +1,146 @@
<template>
<div class="user-info-component" :class="{ 'layout-inline': layout === 'inline' }">
<UserAvatar
:user-id="userId"
:display-name="displayName"
:size="avatarSize"
:is-deleted="isDeleted"
:clickable="clickable"
/>
<div class="user-details" :class="{ 'details-inline': layout === 'inline' }">
<div class="name-and-meta">
<span
v-if="!isDeleted"
class="user-name"
:class="{ clickable: isClickable }"
@click="handleNameClick"
>
{{ displayName || userId }}
</span>
<span v-else class="user-name deleted-user">
{{ displayName || userId }}
</span>
<template v-if="layout === 'inline'">
<span class="meta-separator">·</span>
<span class="meta-content">
<slot name="meta"></slot>
</span>
</template>
</div>
<slot v-if="layout !== 'inline'" name="meta"></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import UserAvatar from './UserAvatar.vue'
export default defineComponent({
name: 'UserInfo',
components: {
UserAvatar,
},
props: {
userId: {
type: String,
required: true,
},
displayName: {
type: String,
default: '',
},
avatarSize: {
type: Number,
default: 32,
},
isDeleted: {
type: Boolean,
default: false,
},
clickable: {
type: Boolean,
default: true,
},
layout: {
type: String as () => 'column' | 'inline',
default: 'column',
validator: (value: string) => ['column', 'inline'].includes(value),
},
},
computed: {
isClickable(): boolean {
return this.clickable && !this.isDeleted
},
},
methods: {
handleNameClick(event: MouseEvent): void {
if (this.isClickable) {
event.stopPropagation()
this.$router.push(`/u/${this.userId}`)
}
},
},
})
</script>
<style scoped lang="scss">
.user-info-component {
display: flex;
align-items: center;
gap: 12px;
// When there's metadata in the slot, align to flex-start (only for column layout)
&:not(.layout-inline):has(.user-details > :nth-child(2)) {
align-items: flex-start;
}
}
.user-details {
display: flex;
flex-direction: column;
gap: 4px;
&.details-inline {
flex-direction: row;
align-items: center;
}
}
.name-and-meta {
display: flex;
align-items: center;
gap: 8px;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
&.clickable {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-primary-element);
}
}
&.deleted-user {
font-style: italic;
opacity: 0.7;
}
}
.meta-separator {
color: var(--color-text-maxcontrast);
opacity: 0.5;
font-size: 0.85rem;
}
.meta-content {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
</style>

View File

@@ -34,6 +34,11 @@ export function useUserRole() {
return userRoles.value.some((role) => role.roleId === 1)
})
const isModerator = computed<boolean>(() => {
// Moderator role has ID 2 (from migration)
return userRoles.value.some((role) => role.roleId === 2)
})
const refresh = () => {
loaded.value = false
const userId = userRoles.value[0]?.userId
@@ -54,6 +59,7 @@ export function useUserRole() {
error,
loaded,
isAdmin,
isModerator,
fetchUserRoles,
refresh,
clear,

View File

@@ -1,190 +0,0 @@
import { t } from '@nextcloud/l10n'
/**
* Emoji groups with names and titles
*/
export interface EmojiItem {
emoji: string
title: string
}
export interface EmojiGroup {
name: string
emojis: EmojiItem[]
}
export const EMOJI_GROUPS: EmojiGroup[] = [
{
name: t('forum', 'Smileys & Emotion'),
emojis: [
{ emoji: '😀', title: t('forum', 'Grinning Face') },
{ emoji: '😃', title: t('forum', 'Grinning Face with Big Eyes') },
{ emoji: '😄', title: t('forum', 'Grinning Face with Smiling Eyes') },
{ emoji: '😁', title: t('forum', 'Beaming Face with Smiling Eyes') },
{ emoji: '😆', title: t('forum', 'Grinning Squinting Face') },
{ emoji: '😅', title: t('forum', 'Grinning Face with Sweat') },
{ emoji: '😂', title: t('forum', 'Face with Tears of Joy') },
{ emoji: '🤣', title: t('forum', 'Rolling on the Floor Laughing') },
{ emoji: '😊', title: t('forum', 'Smiling Face with Smiling Eyes') },
{ emoji: '😇', title: t('forum', 'Smiling Face with Halo') },
{ emoji: '🙂', title: t('forum', 'Slightly Smiling Face') },
{ emoji: '🙃', title: t('forum', 'Upside-Down Face') },
{ emoji: '😉', title: t('forum', 'Winking Face') },
{ emoji: '😌', title: t('forum', 'Relieved Face') },
{ emoji: '😍', title: t('forum', 'Smiling Face with Heart-Eyes') },
{ emoji: '🥰', title: t('forum', 'Smiling Face with Hearts') },
{ emoji: '😘', title: t('forum', 'Face Blowing a Kiss') },
{ emoji: '😗', title: t('forum', 'Kissing Face') },
{ emoji: '😙', title: t('forum', 'Kissing Face with Smiling Eyes') },
{ emoji: '😚', title: t('forum', 'Kissing Face with Closed Eyes') },
{ emoji: '😋', title: t('forum', 'Face Savoring Food') },
{ emoji: '😛', title: t('forum', 'Face with Tongue') },
{ emoji: '😝', title: t('forum', 'Squinting Face with Tongue') },
{ emoji: '😜', title: t('forum', 'Winking Face with Tongue') },
{ emoji: '🤪', title: t('forum', 'Zany Face') },
{ emoji: '🤨', title: t('forum', 'Face with Raised Eyebrow') },
{ emoji: '🧐', title: t('forum', 'Face with Monocle') },
{ emoji: '🤓', title: t('forum', 'Nerd Face') },
{ emoji: '😎', title: t('forum', 'Smiling Face with Sunglasses') },
{ emoji: '🤩', title: t('forum', 'Star-Struck') },
{ emoji: '🥳', title: t('forum', 'Partying Face') },
{ emoji: '😏', title: t('forum', 'Smirking Face') },
{ emoji: '😒', title: t('forum', 'Unamused Face') },
{ emoji: '😞', title: t('forum', 'Disappointed Face') },
{ emoji: '😔', title: t('forum', 'Pensive Face') },
{ emoji: '😟', title: t('forum', 'Worried Face') },
{ emoji: '😕', title: t('forum', 'Confused Face') },
{ emoji: '🙁', title: t('forum', 'Slightly Frowning Face') },
{ emoji: '😣', title: t('forum', 'Persevering Face') },
{ emoji: '😖', title: t('forum', 'Confounded Face') },
{ emoji: '😫', title: t('forum', 'Tired Face') },
{ emoji: '😩', title: t('forum', 'Weary Face') },
{ emoji: '🥺', title: t('forum', 'Pleading Face') },
{ emoji: '😢', title: t('forum', 'Crying Face') },
{ emoji: '😭', title: t('forum', 'Loudly Crying Face') },
{ emoji: '😤', title: t('forum', 'Face with Steam From Nose') },
{ emoji: '😠', title: t('forum', 'Angry Face') },
{ emoji: '😡', title: t('forum', 'Enraged Face') },
{ emoji: '🤬', title: t('forum', 'Face with Symbols on Mouth') },
{ emoji: '🤯', title: t('forum', 'Exploding Head') },
{ emoji: '😳', title: t('forum', 'Flushed Face') },
{ emoji: '🥵', title: t('forum', 'Hot Face') },
{ emoji: '🥶', title: t('forum', 'Cold Face') },
{ emoji: '😱', title: t('forum', 'Face Screaming in Fear') },
{ emoji: '😨', title: t('forum', 'Fearful Face') },
{ emoji: '😰', title: t('forum', 'Anxious Face with Sweat') },
{ emoji: '😥', title: t('forum', 'Sad but Relieved Face') },
{ emoji: '😓', title: t('forum', 'Downcast Face with Sweat') },
{ emoji: '🤗', title: t('forum', 'Smiling Face with Open Hands') },
{ emoji: '🤔', title: t('forum', 'Thinking Face') },
{ emoji: '🤭', title: t('forum', 'Face with Hand Over Mouth') },
{ emoji: '🤫', title: t('forum', 'Shushing Face') },
{ emoji: '🤥', title: t('forum', 'Lying Face') },
{ emoji: '😶', title: t('forum', 'Face Without Mouth') },
{ emoji: '😐', title: t('forum', 'Neutral Face') },
{ emoji: '😑', title: t('forum', 'Expressionless Face') },
{ emoji: '😬', title: t('forum', 'Grimacing Face') },
{ emoji: '🙄', title: t('forum', 'Face with Rolling Eyes') },
{ emoji: '😯', title: t('forum', 'Hushed Face') },
{ emoji: '😦', title: t('forum', 'Frowning Face with Open Mouth') },
{ emoji: '😧', title: t('forum', 'Anguished Face') },
{ emoji: '😮', title: t('forum', 'Face with Open Mouth') },
{ emoji: '😲', title: t('forum', 'Astonished Face') },
{ emoji: '🥱', title: t('forum', 'Yawning Face') },
{ emoji: '😴', title: t('forum', 'Sleeping Face') },
{ emoji: '🤤', title: t('forum', 'Drooling Face') },
{ emoji: '😪', title: t('forum', 'Sleepy Face') },
{ emoji: '😵', title: t('forum', 'Face with Crossed-Out Eyes') },
{ emoji: '🤐', title: t('forum', 'Zipper-Mouth Face') },
{ emoji: '🥴', title: t('forum', 'Woozy Face') },
],
},
{
name: t('forum', 'Gestures & Hands'),
emojis: [
{ emoji: '👋', title: t('forum', 'Waving Hand') },
{ emoji: '🤚', title: t('forum', 'Raised Back of Hand') },
{ emoji: '🖐', title: t('forum', 'Hand with Fingers Splayed') },
{ emoji: '✋', title: t('forum', 'Raised Hand') },
{ emoji: '🖖', title: t('forum', 'Vulcan Salute') },
{ emoji: '👌', title: t('forum', 'OK Hand') },
{ emoji: '🤌', title: t('forum', 'Pinched Fingers') },
{ emoji: '🤏', title: t('forum', 'Pinching Hand') },
{ emoji: '✌️', title: t('forum', 'Victory Hand') },
{ emoji: '🤞', title: t('forum', 'Crossed Fingers') },
{ emoji: '🤟', title: t('forum', 'Love-You Gesture') },
{ emoji: '🤘', title: t('forum', 'Sign of the Horns') },
{ emoji: '🤙', title: t('forum', 'Call Me Hand') },
{ emoji: '👈', title: t('forum', 'Backhand Index Pointing Left') },
{ emoji: '👉', title: t('forum', 'Backhand Index Pointing Right') },
{ emoji: '👆', title: t('forum', 'Backhand Index Pointing Up') },
{ emoji: '🖕', title: t('forum', 'Middle Finger') },
{ emoji: '👇', title: t('forum', 'Backhand Index Pointing Down') },
{ emoji: '☝️', title: t('forum', 'Index Pointing Up') },
{ emoji: '👍', title: t('forum', 'Thumbs Up') },
{ emoji: '👎', title: t('forum', 'Thumbs Down') },
{ emoji: '✊', title: t('forum', 'Raised Fist') },
{ emoji: '👊', title: t('forum', 'Oncoming Fist') },
{ emoji: '🤛', title: t('forum', 'Left-Facing Fist') },
{ emoji: '🤜', title: t('forum', 'Right-Facing Fist') },
{ emoji: '👏', title: t('forum', 'Clapping Hands') },
{ emoji: '🙌', title: t('forum', 'Raising Hands') },
{ emoji: '👐', title: t('forum', 'Open Hands') },
{ emoji: '🤲', title: t('forum', 'Palms Up Together') },
{ emoji: '🤝', title: t('forum', 'Handshake') },
{ emoji: '🙏', title: t('forum', 'Folded Hands') },
],
},
{
name: t('forum', 'Hearts & Love'),
emojis: [
{ emoji: '❤️', title: t('forum', 'Red Heart') },
{ emoji: '💛', title: t('forum', 'Yellow Heart') },
{ emoji: '💙', title: t('forum', 'Blue Heart') },
{ emoji: '💜', title: t('forum', 'Purple Heart') },
{ emoji: '🧡', title: t('forum', 'Orange Heart') },
{ emoji: '💚', title: t('forum', 'Green Heart') },
{ emoji: '🖤', title: t('forum', 'Black Heart') },
{ emoji: '🤍', title: t('forum', 'White Heart') },
{ emoji: '🤎', title: t('forum', 'Brown Heart') },
{ emoji: '💔', title: t('forum', 'Broken Heart') },
{ emoji: '❣️', title: t('forum', 'Heart Exclamation') },
{ emoji: '💕', title: t('forum', 'Two Hearts') },
{ emoji: '💞', title: t('forum', 'Revolving Hearts') },
{ emoji: '💓', title: t('forum', 'Beating Heart') },
{ emoji: '💗', title: t('forum', 'Growing Heart') },
{ emoji: '💖', title: t('forum', 'Sparkling Heart') },
{ emoji: '💘', title: t('forum', 'Heart with Arrow') },
{ emoji: '💝', title: t('forum', 'Heart with Ribbon') },
{ emoji: '💟', title: t('forum', 'Heart Decoration') },
],
},
{
name: t('forum', 'Symbols'),
emojis: [
{ emoji: '🎉', title: t('forum', 'Party Popper') },
{ emoji: '🎊', title: t('forum', 'Confetti Ball') },
{ emoji: '🎈', title: t('forum', 'Balloon') },
{ emoji: '🎁', title: t('forum', 'Wrapped Gift') },
{ emoji: '🏆', title: t('forum', 'Trophy') },
{ emoji: '🥇', title: t('forum', '1st Place Medal') },
{ emoji: '🥈', title: t('forum', '2nd Place Medal') },
{ emoji: '🥉', title: t('forum', '3rd Place Medal') },
{ emoji: '⭐', title: t('forum', 'Star') },
{ emoji: '🌟', title: t('forum', 'Glowing Star') },
{ emoji: '✨', title: t('forum', 'Sparkles') },
{ emoji: '💫', title: t('forum', 'Dizzy') },
{ emoji: '🔥', title: t('forum', 'Fire') },
{ emoji: '💯', title: t('forum', 'Hundred Points') },
{ emoji: '✅', title: t('forum', 'Check Mark Button') },
{ emoji: '❌', title: t('forum', 'Cross Mark') },
{ emoji: '⚠️', title: t('forum', 'Warning') },
{ emoji: '❗', title: t('forum', 'Exclamation Mark') },
{ emoji: '❓', title: t('forum', 'Question Mark') },
{ emoji: '💬', title: t('forum', 'Speech Balloon') },
{ emoji: '💭', title: t('forum', 'Thought Balloon') },
{ emoji: '👀', title: t('forum', 'Eyes') },
],
},
]

View File

@@ -12,6 +12,7 @@ const routes: RouteRecordRaw[] = [
{ path: '/thread/:id', component: () => import('@/views/ThreadView.vue') },
{ path: '/t/:slug', component: () => import('@/views/ThreadView.vue') },
{ path: '/u/:userId', component: () => import('@/views/ProfileView.vue') },
{ path: '/preferences', component: () => import('@/views/UserPreferencesView.vue') },
{ path: '/search', component: () => import('@/views/SearchView.vue') },
{ path: '/admin', component: () => import('@/views/admin/AdminDashboard.vue') },
{ path: '/admin/settings', component: () => import('@/views/admin/AdminGeneralSettings.vue') },

View File

@@ -44,6 +44,7 @@ export interface Thread {
authorIsDeleted?: boolean
categorySlug?: string | null
categoryName?: string | null
isSubscribed?: boolean
}
export interface Post {

View File

@@ -1,431 +0,0 @@
<template>
<div class="user-inner">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<div style="max-width: 320px">
<NcTextField
v-model="search"
:label="strings.searchLabel"
:placeholder="strings.searchPlaceholder"
trailing-button-icon="close"
:show-trailing-button="search !== ''"
@trailing-button-click="clearSearch"
/>
</div>
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
</template>
<template #right>
<NcButton type="secondary" @click="toggleForm">
{{ formOpen ? strings.hideForm : strings.showForm }}
</NcButton>
</template>
</AppToolbar>
<!-- Quick info / doc -->
<NcNoteCard class="mt-12" type="info">
<p v-html="strings.quickHelp"></p>
</NcNoteCard>
<!-- Add item form -->
<section v-if="formOpen" class="card mt-16">
<h3 class="card-title">{{ strings.formHeader }}</h3>
<div class="row gap-16 align-start">
<div style="max-width: 260px">
<NcTextField
v-model="name"
:label="strings.nameInputLabel"
:placeholder="strings.nameInputPlaceholder"
/>
</div>
<div style="max-width: 220px">
<NcSelect
v-model="themeLabel"
:options="themeOptionsLabels"
:input-label="strings.themeLabel"
/>
</div>
<div class="row gap-8 align-center">
<NcButton @click="addFromForm" :disabled="name.trim() === '' || loading">
{{ strings.add }}
</NcButton>
<NcButton type="tertiary" @click="clearForm" :disabled="loading">
{{ strings.clear }}
</NcButton>
</div>
</div>
<p class="mt-12">
{{ strings.livePreview }} <b>{{ previewGreeting }}</b>
</p>
</section>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else-if="filteredHellos.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="seedOne">{{ strings.addExample }}</NcButton>
</template>
</NcEmptyContent>
<!-- List -->
<section v-else class="mt-16">
<table>
<thead>
<tr>
<th style="width: 50%">{{ strings.colMessage }}</th>
<th style="width: 30%">{{ strings.colAt }}</th>
<th style="width: 20%">{{ strings.colActions }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(hello, idx) in filteredHellos" :key="hello.id">
<td class="ellipsis">
<span class="mono">{{ hello.message }}</span>
</td>
<td class="nowrap">
<NcDateTime v-if="hello.at" :timestamp="new Date(hello.at).valueOf()" />
<span v-else class="muted">{{ strings.never }}</span>
</td>
<td>
<div class="row gap-8">
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Footer actions -->
<div class="row gap-12 mt-12">
<NcButton type="secondary" @click="refresh" :disabled="loading">{{
strings.refresh
}}</NcButton>
<NcButton type="secondary" @click="clearAll" :disabled="loading || hellos.length === 0">
{{ strings.clearAll }}
</NcButton>
</div>
</section>
</div>
</template>
<script>
/**
* Inner view rendered inside AppUserWrapper via <router-view>.
* Uses the Hello controller (GET/POST /hello).
*/
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import AppToolbar from '@/components/AppToolbar.vue'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
export default {
name: 'AppUserHome',
components: {
NcButton,
NcNoteCard,
NcTextField,
NcSelect,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
AppToolbar,
},
data() {
return {
loading: false,
formOpen: true,
// Toolbar
search: '',
// Form data
name: '',
themeLabel: null,
themeOptions: [
{ label: t('forum', 'Light'), value: 'light' },
{ label: t('forum', 'Dark'), value: 'dark' },
{
label: n('forum', 'System (1 option)', 'System (%n options)', 2),
value: 'system',
},
],
// List of "hellos"
hellos: [],
strings: {
// Toolbar
searchLabel: t('forum', 'Search'),
searchPlaceholder: t('forum', 'Filter messages…'),
refresh: t('forum', 'Refresh'),
showForm: t('forum', 'Show form'),
hideForm: t('forum', 'Hide form'),
// Info
quickHelp: t(
'forum',
'Use the form to post a hello. The list shows recent hellos fetched from the server. All user-visible text is centralized in {cStart}strings{cEnd}.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
// Form
formHeader: t('forum', 'Say hello'),
nameInputLabel: t('forum', 'Name'),
nameInputPlaceholder: t('forum', 'e.g. Ada'),
themeLabel: t('forum', 'Theme'),
add: t('forum', 'Add'),
clear: t('forum', 'Clear'),
livePreview: t('forum', 'Preview:'),
// List
loading: t('forum', 'Loading…'),
emptyTitle: t('forum', 'No hellos yet'),
emptyDesc: t('forum', 'Try adding one using the form above.'),
addExample: t('forum', 'Add example'),
colMessage: t('forum', 'Message'),
colAt: t('forum', 'Time'),
colActions: t('forum', 'Actions'),
duplicate: t('forum', 'Duplicate'),
remove: t('forum', 'Remove'),
clearAll: t('forum', 'Clear all'),
never: t('forum', 'Never'),
},
}
},
created() {
this.refresh()
},
computed: {
themeOptionsLabels() {
return this.themeOptions.map((x) => x.label)
},
activeTheme() {
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
},
previewGreeting() {
const n = this.name.trim()
return n ? `Hello, ${n}!` : 'Hello!'
},
filteredHellos() {
const q = this.search.trim().toLowerCase()
if (!q) return this.hellos
return this.hellos.filter((h) => h.message.toLowerCase().includes(q))
},
},
methods: {
toggleForm() {
this.formOpen = !this.formOpen
},
clearForm() {
this.name = ''
this.themeLabel = null
},
clearSearch() {
this.search = ''
},
async refresh() {
try {
this.loading = true
// GET /hello -> { ocs: { data: { message, at } } }
const resp = await ocs.get('/hello')
const data = resp.data
if (data?.message) {
this.hellos.unshift({
id: genId(),
message: data.message,
at: data.at ?? null,
})
}
} catch (e) {
console.error('Failed to refresh', e)
} finally {
this.loading = false
}
},
async addFromForm() {
const name = this.name.trim()
if (!name) return
try {
this.loading = true
const payload = {
name,
theme: this.activeTheme.value,
items: [],
counter: 0,
}
// POST /hello -> { ocs: { data: { message, at } } }
const resp = await ocs.post('/hello', { data: payload })
const data = resp.data
const message = data?.message ?? `Hello, ${name}!`
const at = data?.at ?? new Date().toISOString()
this.hellos.unshift({ id: genId(), message, at })
this.clearForm()
this.formOpen = false
} catch (e) {
console.error('Failed to add hello', e)
} finally {
this.loading = false
}
},
duplicate(index) {
const src = this.hellos[index]
if (!src) return
this.hellos.splice(index + 1, 0, { ...src, id: genId() })
},
remove(index) {
this.hellos.splice(index, 1)
},
clearAll() {
this.hellos = []
},
seedOne() {
this.hellos.push({
id: genId(),
message: '👋 Hello example',
at: new Date().toISOString(),
})
},
},
}
function genId() {
return Math.random().toString(36).slice(2, 10)
}
</script>
<style scoped lang="scss">
.user-inner {
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
.mono {
font-family: var(--font-monospace);
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.ml-8 {
margin-left: 8px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.row {
display: flex;
&.align-start {
align-items: flex-start;
}
&.align-center {
align-items: center;
}
&.gap-8 {
gap: 8px;
}
&.gap-12 {
gap: 12px;
}
&.gap-16 {
gap: 16px;
}
}
.card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 12px;
background: var(--color-main-background);
}
.card-title {
margin: 0 0 8px 0;
font-size: 1rem;
font-weight: 600;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border);
thead tr,
tr:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
thead,
tbody tr {
display: table;
width: 100%;
table-layout: fixed;
}
th,
td {
padding: 8px;
vertical-align: middle;
}
.nowrap {
white-space: nowrap;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@@ -1,64 +1,60 @@
<template>
<div class="categories-view">
<header class="page-header">
<h2>{{ forumTitle }}</h2>
<p class="muted">{{ forumSubtitle }}</p>
</header>
<PageWrapper :full-width="true">
<template #toolbar>
<AppToolbar>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
</template>
</AppToolbar>
</template>
<!-- Toolbar -->
<AppToolbar>
<template #left>
<h2 class="view-title">{{ strings.title }}</h2>
</template>
<div class="categories-view">
<PageHeader :title="forumTitle" :subtitle="forumSubtitle" :loading="settingsLoading" />
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
</template>
</AppToolbar>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else-if="categoryHeaders.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
<!-- Categories list -->
<section v-else class="mt-16">
<div v-for="header in categoryHeaders" :key="header.id" class="header-section">
<h3 class="header-title">{{ header.name }}</h3>
<!-- Categories grid -->
<div v-if="header.categories && header.categories.length > 0" class="categories-grid">
<CategoryCard
v-for="category in header.categories"
:key="category.id"
:category="category"
@click="navigateToCategory(category)"
/>
</div>
<!-- Empty state for header with no categories -->
<p v-else class="no-categories muted">{{ strings.noCategories }}</p>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
</section>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else-if="categoryHeaders.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
<!-- Categories list -->
<section v-else class="mt-16">
<div v-for="header in categoryHeaders" :key="header.id" class="header-section">
<h3 class="header-title">{{ header.name }}</h3>
<!-- Categories grid -->
<div v-if="header.categories && header.categories.length > 0" class="categories-grid">
<CategoryCard
v-for="category in header.categories"
:key="category.id"
:category="category"
@click="navigateToCategory(category)"
/>
</div>
<!-- Empty state for header with no categories -->
<p v-else class="no-categories muted">{{ strings.noCategories }}</p>
</div>
</section>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -67,6 +63,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import CategoryCard from '@/components/CategoryCard.vue'
import RefreshIcon from '@icons/Refresh.vue'
import { useCategories } from '@/composables/useCategories'
@@ -81,6 +79,8 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
AppToolbar,
PageWrapper,
PageHeader,
CategoryCard,
RefreshIcon,
},
@@ -95,10 +95,10 @@ export default defineComponent({
},
data() {
return {
settingsLoading: true,
forumTitle: t('forum', 'Forum'),
forumSubtitle: t('forum', 'Welcome to the forum'),
strings: {
title: t('forum', 'Categories'),
refresh: t('forum', 'Refresh'),
loading: t('forum', 'Loading…'),
emptyTitle: t('forum', 'No categories yet'),
@@ -118,12 +118,15 @@ export default defineComponent({
methods: {
async fetchForumSettings() {
try {
const response = await ocs.get<{ title: string; subtitle: string }>('/admin/settings')
this.settingsLoading = true
const response = await ocs.get<{ title: string; subtitle: string }>('/settings')
this.forumTitle = response.data.title || t('forum', 'Forum')
this.forumSubtitle = response.data.subtitle || t('forum', 'Welcome to the forum')
this.forumSubtitle = response.data.subtitle || t('forum', 'Welcome to the forum!')
} catch (e) {
// Silently fail and use defaults if settings can't be loaded
console.debug('Could not load forum settings, using defaults', e)
} finally {
this.settingsLoading = false
}
},

View File

@@ -1,100 +1,105 @@
<template>
<div class="category-view">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
<PageWrapper :full-width="true">
<template #toolbar>
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<NcButton @click="createThread" :disabled="loading" variant="primary">
<template #icon>
<MessagePlusIcon :size="20" />
</template>
{{ strings.newThread }}
</NcButton>
</template>
</AppToolbar>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<NcButton @click="createThread" :disabled="loading" variant="primary">
<template #icon>
<MessagePlusIcon :size="20" />
</template>
{{ strings.newThread }}
</NcButton>
</template>
</AppToolbar>
</template>
<!-- Category Header -->
<div v-if="category && !loading" class="category-header mt-16">
<h2 class="category-name">{{ category.name }}</h2>
<p v-if="category.description" class="category-description">{{ category.description }}</p>
</div>
<div class="category-view">
<!-- Category Header -->
<PageHeader
v-if="category && !loading"
:title="category.name"
:subtitle="category.description || undefined"
class="mt-16"
/>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Empty state -->
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createThread" variant="primary">
<template #icon>
<MessagePlusIcon :size="20" />
</template>
{{ strings.newThread }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Threads list -->
<section v-else class="mt-16">
<div class="threads-list">
<ThreadCard
v-for="thread in sortedThreads"
:key="thread.id"
:thread="thread"
:is-unread="isThreadUnread(thread)"
@click="navigateToThread(thread)"
/>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Pagination info -->
<div v-if="threads.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingThreads(threads.length) }}</p>
</div>
</section>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Empty state -->
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createThread" variant="primary">
<template #icon>
<MessagePlusIcon :size="20" />
</template>
{{ strings.newThread }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Threads list -->
<section v-else class="mt-16">
<div class="threads-list">
<ThreadCard
v-for="thread in sortedThreads"
:key="thread.id"
:thread="thread"
:is-unread="isThreadUnread(thread)"
@click="navigateToThread(thread)"
/>
</div>
<!-- Pagination info -->
<div v-if="threads.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingThreads(threads.length) }}</p>
</div>
</section>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -103,6 +108,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import ThreadCard from '@/components/ThreadCard.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
@@ -118,6 +125,8 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
AppToolbar,
PageWrapper,
PageHeader,
ThreadCard,
ArrowLeftIcon,
RefreshIcon,
@@ -299,27 +308,6 @@ export default defineComponent({
}
.category-header {
padding: 20px;
background: var(--color-background-hover);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.category-name {
margin: 0 0 8px 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-main-text);
}
.category-description {
margin: 0;
font-size: 1rem;
color: var(--color-text-lighter);
line-height: 1.5;
}
.threads-list {
display: flex;
flex-direction: column;

View File

@@ -1,51 +1,55 @@
<template>
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
<PageWrapper>
<template #toolbar>
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
{{ strings.back }}
</NcButton>
</AppToolbar>
</template>
</AppToolbar>
<div class="create-thread-view">
<!-- Page Header -->
<div class="page-header mt-16">
<h2 class="page-title">{{ strings.title }}</h2>
<p v-if="category" class="page-subtitle">{{ strings.subtitle(category.name) }}</p>
<div class="create-thread-view">
<!-- Page Header -->
<PageHeader
:title="strings.title"
:subtitle="category ? strings.subtitle(category.name) : ''"
class="mt-16"
/>
<!-- Loading state -->
<div class="center mt-16" v-if="loading && !category">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Create Thread Form -->
<div v-else class="mt-16">
<ThreadCreateForm ref="createForm" @submit="handleCreateThread" @cancel="goBack" />
</div>
</div>
<!-- Loading state -->
<div class="center mt-16" v-if="loading && !category">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Create Thread Form -->
<div v-else class="mt-16">
<ThreadCreateForm ref="createForm" @submit="handleCreateThread" @cancel="goBack" />
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -54,6 +58,8 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import ThreadCreateForm from '@/components/ThreadCreateForm.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import type { Category, Thread } from '@/types'
@@ -68,6 +74,8 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
AppToolbar,
PageWrapper,
PageHeader,
ThreadCreateForm,
ArrowLeftIcon,
},
@@ -172,9 +180,6 @@ export default defineComponent({
<style scoped lang="scss">
.create-thread-view {
max-width: 900px;
margin: 0 auto;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;

View File

@@ -1,154 +1,157 @@
<template>
<div class="profile-view">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
<PageWrapper>
<template #toolbar>
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
</template>
</AppToolbar>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
</template>
</AppToolbar>
</template>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Profile content -->
<div v-else class="profile-content mt-16">
<!-- User Header -->
<div class="user-header">
<div class="user-avatar">
<NcAvatar :user="userId" :size="80" :show-user-status="false" />
</div>
<div class="user-info">
<h2 class="user-name">{{ displayName }}</h2>
<div class="user-meta">
<span v-if="userStats && userStats.createdAt" class="meta-item">
<span class="meta-label">{{ strings.firstPost }}</span>
<NcDateTime :timestamp="userStats.createdAt * 1000" />
</span>
<span v-if="userStats && userStats.createdAt" class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.threads }}</span>
<span class="meta-value">{{ userStats?.threadCount || 0 }}</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.posts }}</span>
<span class="meta-value">{{ userStats?.postCount || 0 }}</span>
</span>
</div>
</div>
<div class="profile-view">
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Tabs -->
<div class="profile-tabs mt-24">
<div class="tabs-header">
<button
class="tab-button"
:class="{ active: activeTab === 'threads' }"
@click="activeTab = 'threads'"
>
{{ strings.threads }} ({{ threads.length }})
</button>
<button
class="tab-button"
:class="{ active: activeTab === 'posts' }"
@click="activeTab = 'posts'"
>
{{ strings.replies }} ({{ posts.length }})
</button>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<div class="tabs-content mt-16">
<!-- Threads Tab -->
<div v-if="activeTab === 'threads'" class="tab-pane">
<div v-if="loadingThreads" class="center">
<NcLoadingIcon :size="24" />
</div>
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.noThreads"
:description="strings.noThreadsDesc"
/>
<div v-else class="threads-list">
<ThreadCard
v-for="thread in threads"
:key="thread.id"
:thread="thread"
@click="navigateToThread(thread)"
/>
<!-- Profile content -->
<div v-else class="profile-content mt-16">
<!-- User Header -->
<div class="user-header">
<div class="user-avatar">
<NcAvatar :user="userId" :size="80" :show-user-status="false" />
</div>
<div class="user-info">
<h2 class="user-name">{{ displayName }}</h2>
<div class="user-meta">
<span v-if="userStats && userStats.createdAt" class="meta-item">
<span class="meta-label">{{ strings.firstPost }}</span>
<NcDateTime :timestamp="userStats.createdAt * 1000" />
</span>
<span v-if="userStats && userStats.createdAt" class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.threads }}</span>
<span class="meta-value">{{ userStats?.threadCount || 0 }}</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.posts }}</span>
<span class="meta-value">{{ userStats?.postCount || 0 }}</span>
</span>
</div>
</div>
</div>
<!-- Posts Tab -->
<div v-if="activeTab === 'posts'" class="tab-pane">
<div v-if="loadingPosts" class="center">
<NcLoadingIcon :size="24" />
<!-- Tabs -->
<div class="profile-tabs mt-24">
<div class="tabs-header">
<button
class="tab-button"
:class="{ active: activeTab === 'threads' }"
@click="activeTab = 'threads'"
>
{{ strings.threads }} ({{ threads.length }})
</button>
<button
class="tab-button"
:class="{ active: activeTab === 'posts' }"
@click="activeTab = 'posts'"
>
{{ strings.replies }} ({{ posts.length }})
</button>
</div>
<div class="tabs-content mt-16">
<!-- Threads Tab -->
<div v-if="activeTab === 'threads'" class="tab-pane">
<div v-if="loadingThreads" class="center">
<NcLoadingIcon :size="24" />
</div>
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.noThreads"
:description="strings.noThreadsDesc"
/>
<div v-else class="threads-list">
<ThreadCard
v-for="thread in threads"
:key="thread.id"
:thread="thread"
@click="navigateToThread(thread)"
/>
</div>
</div>
<NcEmptyContent
v-else-if="posts.length === 0"
:title="strings.noPosts"
:description="strings.noPostsDesc"
/>
<div v-else class="posts-list">
<div
v-for="post in posts"
:key="post.id"
class="post-item"
@click="navigateToPost(post)"
>
<div class="post-meta">
<span class="post-thread" v-if="post.threadTitle">
{{ strings.inThread }} <strong>{{ post.threadTitle }}</strong>
</span>
<span class="post-date">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
</span>
<!-- Posts Tab -->
<div v-if="activeTab === 'posts'" class="tab-pane">
<div v-if="loadingPosts" class="center">
<NcLoadingIcon :size="24" />
</div>
<NcEmptyContent
v-else-if="posts.length === 0"
:title="strings.noPosts"
:description="strings.noPostsDesc"
/>
<div v-else class="posts-list">
<div
v-for="post in posts"
:key="post.id"
class="post-item"
@click="navigateToPost(post)"
>
<div class="post-meta">
<span class="post-thread" v-if="post.threadTitle">
{{ strings.inThread }} <strong>{{ post.threadTitle }}</strong>
</span>
<span class="post-date">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
</span>
</div>
<div class="post-content" v-html="post.content"></div>
</div>
<div class="post-content" v-html="post.content"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -159,6 +162,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import ThreadCard from '@/components/ThreadCard.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
@@ -177,6 +181,7 @@ export default defineComponent({
NcAvatar,
NcDateTime,
AppToolbar,
PageWrapper,
ThreadCard,
ArrowLeftIcon,
RefreshIcon,
@@ -358,158 +363,225 @@ export default defineComponent({
<style scoped lang="scss">
.profile-view {
padding: 16px;
max-width: 1200px;
margin: 0 auto;
}
@media (max-width: 768px) {
padding: 0 8px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.ml-8 {
margin-left: 8px;
}
.mt-16 {
margin-top: 16px;
}
.mt-24 {
margin-top: 24px;
}
.muted {
color: var(--color-text-maxcontrast);
}
.user-header {
display: flex;
align-items: center;
gap: 24px;
padding: 24px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
}
.user-info {
flex: 1;
}
.user-name {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
}
.user-meta {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-maxcontrast);
font-size: 14px;
}
.meta-label {
margin-right: 4px;
}
.meta-value {
font-weight: 600;
color: var(--color-text-light);
}
.meta-divider {
color: var(--color-text-maxcontrast);
}
.profile-tabs {
.tabs-header {
.center {
display: flex;
border-bottom: 1px solid var(--color-border);
align-items: center;
justify-content: center;
padding: 32px;
@media (max-width: 768px) {
padding: 16px;
}
}
.tab-button {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 14px;
font-weight: 500;
.ml-8 {
margin-left: 8px;
}
.mt-16 {
margin-top: 16px;
}
.mt-24 {
margin-top: 24px;
@media (max-width: 768px) {
margin-top: 16px;
}
}
.muted {
color: var(--color-text-maxcontrast);
transition: all 0.2s;
border-radius: 0;
}
&:hover {
color: var(--color-text-light);
background: var(--color-background-hover);
}
.user-header {
display: flex;
align-items: center;
gap: 24px;
padding: 24px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
&.active {
color: var(--color-text-light);
border-bottom-color: var(--color-text-light);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 16px;
padding: 16px;
}
}
.tabs-content {
min-height: 200px;
.user-avatar {
@media (max-width: 768px) {
align-self: center;
}
}
}
.threads-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.user-info {
flex: 1;
width: 100%;
.posts-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.post-item {
padding: 16px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--color-background-hover);
border-color: var(--color-primary-element);
@media (max-width: 768px) {
text-align: center;
}
}
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: var(--color-text-maxcontrast);
}
.user-name {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
.post-thread {
strong {
@media (max-width: 768px) {
font-size: 20px;
}
}
.user-meta {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-maxcontrast);
font-size: 14px;
flex-wrap: wrap;
@media (max-width: 768px) {
justify-content: center;
font-size: 13px;
}
}
.meta-item {
display: inline-flex;
align-items: center;
}
.meta-label {
margin-right: 4px;
}
.meta-value {
font-weight: 600;
color: var(--color-text-light);
}
}
.post-content {
color: var(--color-text-light);
line-height: 1.6;
.meta-divider {
color: var(--color-text-maxcontrast);
// Truncate long content
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
@media (max-width: 480px) {
display: none;
}
}
.profile-tabs {
.tabs-header {
display: flex;
border-bottom: 1px solid var(--color-border);
}
.tab-button {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--color-text-maxcontrast);
transition: all 0.2s;
border-radius: 0;
flex: 1;
@media (max-width: 768px) {
padding: 12px 16px;
font-size: 13px;
}
&:hover {
color: var(--color-text-light);
background: var(--color-background-hover);
}
&.active {
color: var(--color-text-light);
border-bottom-color: var(--color-text-light);
}
}
.tabs-content {
min-height: 200px;
}
}
.threads-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.post-item {
padding: 16px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
@media (max-width: 768px) {
padding: 12px;
}
&:hover {
background: var(--color-background-hover);
border-color: var(--color-primary-element);
}
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: var(--color-text-maxcontrast);
gap: 8px;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 4px;
font-size: 13px;
}
}
.post-thread {
strong {
color: var(--color-text-light);
}
@media (max-width: 768px) {
word-break: break-word;
}
}
.post-content {
color: var(--color-text-light);
line-height: 1.6;
// Truncate long content
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
</style>

View File

@@ -1,132 +1,134 @@
<template>
<div class="search-view">
<!-- Search Header -->
<div class="search-header">
<h2 class="search-title">{{ strings.searchTitle }}</h2>
<PageWrapper>
<div class="search-view">
<!-- Search Header -->
<div class="search-header">
<h2 class="search-title">{{ strings.searchTitle }}</h2>
<!-- Search Input -->
<div class="search-input-wrapper">
<input
v-model="searchQuery"
type="text"
:placeholder="strings.searchPlaceholder"
class="search-input"
@keydown.enter="performSearch"
/>
<NcButton variant="primary" @click="performSearch" :disabled="!canSearch || loading">
<template #icon>
<MagnifyIcon :size="20" />
</template>
{{ strings.search }}
</NcButton>
</div>
<!-- Search Options -->
<div class="search-options">
<NcCheckboxRadioSwitch v-model="searchThreads" @update:checked="onOptionsChange">
{{ strings.searchThreads }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="searchPosts" @update:checked="onOptionsChange">
{{ strings.searchPosts }}
</NcCheckboxRadioSwitch>
<NcButton variant="tertiary" @click="showSyntaxHelp = !showSyntaxHelp">
<template #icon>
<HelpCircleIcon :size="20" />
</template>
{{ strings.syntaxHelp }}
</NcButton>
</div>
<!-- Syntax Help -->
<div v-if="showSyntaxHelp" class="syntax-help">
<h3>{{ strings.searchSyntax }}</h3>
<ul>
<li><code>"exact phrase"</code> - {{ strings.helpExactPhrase }}</li>
<li><code>term1 AND term2</code> - {{ strings.helpAnd }}</li>
<li><code>term1 OR term2</code> - {{ strings.helpOr }}</li>
<li><code>(term1 OR term2) AND term3</code> - {{ strings.helpGrouping }}</li>
<li><code>-excluded</code> - {{ strings.helpExclude }}</li>
</ul>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.searching }}</span>
</div>
<!-- Error State -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="performSearch">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Empty State (no query) -->
<NcEmptyContent
v-else-if="!hasSearched"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #icon>
<MagnifyIcon :size="64" />
</template>
</NcEmptyContent>
<!-- No Results -->
<NcEmptyContent
v-else-if="hasSearched && threadResults.length === 0 && postResults.length === 0"
:title="strings.noResultsTitle"
:description="strings.noResultsDesc"
class="mt-16"
>
<template #icon>
<MagnifyIcon :size="64" />
</template>
</NcEmptyContent>
<!-- Results -->
<div v-else class="search-results mt-16">
<!-- Thread Results Section -->
<section v-if="searchThreads && threadResults.length > 0" class="results-section">
<h3 class="results-header">
{{ strings.threadResults(threadCount) }}
</h3>
<div class="results-list">
<SearchThreadResult
v-for="thread in threadResults"
:key="thread.id"
:thread="thread"
:query="currentQuery"
@click="navigateToThread(thread)"
<!-- Search Input -->
<div class="search-input-wrapper">
<input
v-model="searchQuery"
type="text"
:placeholder="strings.searchPlaceholder"
class="search-input"
@keydown.enter="performSearch"
/>
<NcButton variant="primary" @click="performSearch" :disabled="!canSearch || loading">
<template #icon>
<MagnifyIcon :size="20" />
</template>
{{ strings.search }}
</NcButton>
</div>
</section>
<!-- Post Results Section -->
<section v-if="searchPosts && postResults.length > 0" class="results-section mt-16">
<h3 class="results-header">
{{ strings.postResults(postCount) }}
</h3>
<div class="results-list">
<SearchPostResult
v-for="post in postResults"
:key="post.id"
:post="post"
:query="currentQuery"
/>
<!-- Search Options -->
<div class="search-options">
<NcCheckboxRadioSwitch v-model="searchThreads" @update:checked="onOptionsChange">
{{ strings.searchThreads }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="searchPosts" @update:checked="onOptionsChange">
{{ strings.searchPosts }}
</NcCheckboxRadioSwitch>
<NcButton variant="tertiary" @click="showSyntaxHelp = !showSyntaxHelp">
<template #icon>
<HelpCircleIcon :size="20" />
</template>
{{ strings.syntaxHelp }}
</NcButton>
</div>
</section>
<!-- Syntax Help -->
<div v-if="showSyntaxHelp" class="syntax-help">
<h3>{{ strings.searchSyntax }}</h3>
<ul>
<li><code>"exact phrase"</code> - {{ strings.helpExactPhrase }}</li>
<li><code>term1 AND term2</code> - {{ strings.helpAnd }}</li>
<li><code>term1 OR term2</code> - {{ strings.helpOr }}</li>
<li><code>(term1 OR term2) AND term3</code> - {{ strings.helpGrouping }}</li>
<li><code>-excluded</code> - {{ strings.helpExclude }}</li>
</ul>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.searching }}</span>
</div>
<!-- Error State -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="performSearch">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Empty State (no query) -->
<NcEmptyContent
v-else-if="!hasSearched"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #icon>
<MagnifyIcon :size="64" />
</template>
</NcEmptyContent>
<!-- No Results -->
<NcEmptyContent
v-else-if="hasSearched && threadResults.length === 0 && postResults.length === 0"
:title="strings.noResultsTitle"
:description="strings.noResultsDesc"
class="mt-16"
>
<template #icon>
<MagnifyIcon :size="64" />
</template>
</NcEmptyContent>
<!-- Results -->
<div v-else class="search-results mt-16">
<!-- Thread Results Section -->
<section v-if="searchThreads && threadResults.length > 0" class="results-section">
<h3 class="results-header">
{{ strings.threadResults(threadCount) }}
</h3>
<div class="results-list">
<SearchThreadResult
v-for="thread in threadResults"
:key="thread.id"
:thread="thread"
:query="currentQuery"
@click="navigateToThread(thread)"
/>
</div>
</section>
<!-- Post Results Section -->
<section v-if="searchPosts && postResults.length > 0" class="results-section mt-16">
<h3 class="results-header">
{{ strings.postResults(postCount) }}
</h3>
<div class="results-list">
<SearchPostResult
v-for="post in postResults"
:key="post.id"
:post="post"
:query="currentQuery"
/>
</div>
</section>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -135,6 +137,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import PageWrapper from '@/components/PageWrapper.vue'
import MagnifyIcon from '@icons/Magnify.vue'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import SearchThreadResult from '@/components/SearchThreadResult.vue'
@@ -151,6 +154,7 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
NcCheckboxRadioSwitch,
PageWrapper,
MagnifyIcon,
HelpCircleIcon,
SearchThreadResult,
@@ -276,10 +280,6 @@ export default defineComponent({
<style scoped lang="scss">
.search-view {
max-width: 900px;
margin: 0 auto;
padding: 20px;
.search-header {
margin-bottom: 24px;
}

View File

@@ -1,191 +1,209 @@
<template>
<div class="thread-view">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ thread?.categoryName ? strings.backToCategory(thread.categoryName) : strings.back }}
</NcButton>
</template>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<!-- Moderation buttons (only visible to moderators) -->
<template v-if="canModerate && !loading">
<NcButton
@click="handleToggleLock"
:aria-label="thread?.isLocked ? strings.unlockThread : strings.lockThread"
:title="thread?.isLocked ? strings.unlockThread : strings.lockThread"
>
<PageWrapper :full-width="true">
<template #toolbar>
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<LockOpenIcon v-if="thread?.isLocked" :size="20" />
<LockIcon v-else :size="20" />
</template>
</NcButton>
<NcButton
@click="handleTogglePin"
:aria-label="thread?.isPinned ? strings.unpinThread : strings.pinThread"
:title="thread?.isPinned ? strings.unpinThread : strings.pinThread"
>
<template #icon>
<PinOffIcon v-if="thread?.isPinned" :size="20" />
<PinIcon v-else :size="20" />
<ArrowLeftIcon :size="20" />
</template>
{{ thread?.categoryName ? strings.backToCategory(thread.categoryName) : strings.back }}
</NcButton>
</template>
<NcButton
@click="replyToThread"
:disabled="loading || (thread?.isLocked && !canModerate)"
variant="primary"
>
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</AppToolbar>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Thread Header -->
<div v-else-if="thread" class="thread-header mt-16">
<div class="thread-title-section">
<h2 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="20" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<LockIcon :size="20" />
</span>
{{ thread.title }}
</h2>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
{{ thread.authorDisplayName || thread.authorId }}
<template #right>
<!-- Subscription toggle switch -->
<NcCheckboxRadioSwitch
v-if="!loading && thread"
v-model="thread.isSubscribed"
@update:model-value="handleToggleSubscription"
type="switch"
>
<span class="icon-label">
<BellIcon :size="20" />
{{ thread.isSubscribed ? strings.subscribed : strings.subscribe }}
</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="stat-icon">
<EyeIcon :size="16" />
</span>
<span class="stat-label">{{ strings.views(thread.viewCount) }}</span>
</span>
</div>
</div>
</div>
</NcCheckboxRadioSwitch>
<!-- Posts list -->
<section v-if="!loading && !error && posts.length > 0" class="mt-16">
<div class="posts-list">
<PostCard
v-for="(post, index) in posts"
:key="post.id"
:ref="(el) => setPostCardRef(el, post.id)"
:post="post"
:is-first-post="index === 0"
:is-unread="isPostUnread(post)"
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<!-- Pagination info -->
<div v-if="posts.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingPosts(posts.length) }}</p>
</div>
</section>
<!-- Moderation buttons (only visible to moderators) -->
<template v-if="canModerate && !loading">
<NcButton
@click="handleToggleLock"
:aria-label="thread?.isLocked ? strings.unlockThread : strings.lockThread"
:title="thread?.isLocked ? strings.unlockThread : strings.lockThread"
>
<template #icon>
<LockOpenIcon v-if="thread?.isLocked" :size="20" />
<LockIcon v-else :size="20" />
</template>
</NcButton>
<!-- Empty posts state (thread exists but no posts) -->
<NcEmptyContent
v-else-if="!loading && !error && thread && posts.length === 0"
:title="strings.emptyPostsTitle"
:description="strings.emptyPostsDesc"
class="mt-16"
>
<template #action>
<NcButton @click="replyToThread" variant="primary">
<template #icon>
<ReplyIcon :size="20" />
<NcButton
@click="handleTogglePin"
:aria-label="thread?.isPinned ? strings.unpinThread : strings.pinThread"
:title="thread?.isPinned ? strings.unpinThread : strings.pinThread"
>
<template #icon>
<PinOffIcon v-if="thread?.isPinned" :size="20" />
<PinIcon v-else :size="20" />
</template>
</NcButton>
</template>
{{ strings.reply }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Locked message (only shown to non-moderators) -->
<div
v-if="!loading && !error && thread && thread.isLocked && !canModerate"
class="locked-message mt-16"
>
<NcEmptyContent :title="strings.locked" :description="strings.lockedMessage">
<template #icon>
<LockIcon :size="64" />
<NcButton
@click="replyToThread"
:disabled="loading || (thread?.isLocked && !canModerate)"
variant="primary"
>
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="thread-view">
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
</div>
<!-- Reply form (moderators can reply even when locked) -->
<PostReplyForm
v-if="!loading && !error && thread && (!thread.isLocked || canModerate)"
ref="replyForm"
@submit="handleSubmitReply"
@cancel="handleCancelReply"
/>
</div>
<!-- Thread Header -->
<div v-else-if="thread" class="thread-header mt-16">
<div class="thread-title-section">
<h2 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="20" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<LockIcon :size="20" />
</span>
{{ thread.title }}
</h2>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
{{ thread.authorDisplayName || thread.authorId }}
</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="stat-icon">
<EyeIcon :size="16" />
</span>
<span class="stat-label">{{ strings.views(thread.viewCount) }}</span>
</span>
</div>
</div>
</div>
<!-- Posts list -->
<section v-if="!loading && !error && posts.length > 0" class="mt-16">
<div class="posts-list">
<PostCard
v-for="(post, index) in posts"
:key="post.id"
:ref="(el) => setPostCardRef(el, post.id)"
:post="post"
:is-first-post="index === 0"
:is-unread="isPostUnread(post)"
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
<!-- Pagination info -->
<div v-if="posts.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingPosts(posts.length) }}</p>
</div>
</section>
<!-- Empty posts state (thread exists but no posts) -->
<NcEmptyContent
v-else-if="!loading && !error && thread && posts.length === 0"
:title="strings.emptyPostsTitle"
:description="strings.emptyPostsDesc"
class="mt-16"
>
<template #action>
<NcButton @click="replyToThread" variant="primary">
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Locked message (only shown to non-moderators) -->
<div
v-if="!loading && !error && thread && thread.isLocked && !canModerate"
class="locked-message mt-16"
>
<NcEmptyContent :title="strings.locked" :description="strings.lockedMessage">
<template #icon>
<LockIcon :size="64" />
</template>
</NcEmptyContent>
</div>
<!-- Reply form (moderators can reply even when locked) -->
<PostReplyForm
v-if="!loading && !error && thread && (!thread.isLocked || canModerate)"
ref="replyForm"
@submit="handleSubmitReply"
@cancel="handleCancelReply"
/>
</div>
</PageWrapper>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PostCard from '@/components/PostCard.vue'
import PostReplyForm from '@/components/PostReplyForm.vue'
import PinIcon from '@icons/Pin.vue'
@@ -193,6 +211,7 @@ import PinOffIcon from '@icons/PinOff.vue'
import LockIcon from '@icons/Lock.vue'
import LockOpenIcon from '@icons/LockOpen.vue'
import EyeIcon from '@icons/Eye.vue'
import BellIcon from '@icons/Bell.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
import ReplyIcon from '@icons/Reply.vue'
@@ -207,10 +226,12 @@ export default defineComponent({
name: 'ThreadView',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
AppToolbar,
PageWrapper,
PostCard,
PostReplyForm,
PinIcon,
@@ -218,6 +239,7 @@ export default defineComponent({
LockIcon,
LockOpenIcon,
EyeIcon,
BellIcon,
ArrowLeftIcon,
RefreshIcon,
ReplyIcon,
@@ -268,6 +290,10 @@ export default defineComponent({
threadUnlocked: t('forum', 'Thread unlocked'),
threadPinned: t('forum', 'Thread pinned'),
threadUnpinned: t('forum', 'Thread unpinned'),
subscribe: t('forum', 'Subscribe'),
subscribed: t('forum', 'Subscribed'),
threadSubscribed: t('forum', 'Subscribed to thread'),
threadUnsubscribed: t('forum', 'Unsubscribed from thread'),
},
}
},
@@ -610,6 +636,29 @@ export default defineComponent({
}
},
async handleToggleSubscription(newValue: boolean): Promise<void> {
if (!this.thread) return
try {
if (newValue) {
// Subscribe to thread
await ocs.post(`/threads/${this.thread.id}/subscribe`)
this.thread.isSubscribed = true
showSuccess(this.strings.threadSubscribed)
} else {
// Unsubscribe from thread
await ocs.delete(`/threads/${this.thread.id}/subscribe`)
this.thread.isSubscribed = false
showSuccess(this.strings.threadUnsubscribed)
}
} catch (e) {
console.error('Failed to toggle thread subscription', e)
showError(t('forum', 'Failed to update subscription'))
// Revert the state on error
this.thread.isSubscribed = !newValue
}
},
scrollToPostFromHash(): void {
// Check if there's a hash in the URL like #post-123
const hash = window.location.hash || this.$route.hash
@@ -683,6 +732,12 @@ export default defineComponent({
</script>
<style scoped lang="scss">
:deep(.icon-label) {
display: flex;
align-items: center;
gap: 4px;
}
.thread-view {
margin-bottom: 3rem;
@@ -820,6 +875,7 @@ export default defineComponent({
background-color: var(--color-primary-element-light);
box-shadow: 0 0 0 4px var(--color-primary-element-light);
}
100% {
background-color: transparent;
box-shadow: none;

View File

@@ -0,0 +1,282 @@
<template>
<PageWrapper>
<template #toolbar>
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="user-preferences-view">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="loadPreferences">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Preferences form -->
<div v-else class="preferences-form">
<!-- Thread Subscriptions Section -->
<div class="form-section">
<h3>{{ strings.subscriptionsTitle }}</h3>
<p class="section-description muted">{{ strings.subscriptionsDesc }}</p>
<div class="preference-item">
<NcCheckboxRadioSwitch v-model="formData.auto_subscribe_created_threads">
{{ strings.autoSubscribeLabel }}
</NcCheckboxRadioSwitch>
<p class="preference-hint">{{ strings.autoSubscribeHint }}</p>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<NcButton variant="primary" :disabled="saving || !hasChanges" @click="savePreferences">
<template #icon>
<NcLoadingIcon v-if="saving" :size="20" />
<CheckIcon v-else :size="20" />
</template>
{{ strings.save }}
</NcButton>
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
{{ strings.cancel }}
</NcButton>
</div>
<!-- Success message -->
<div v-if="saveSuccess" class="success-message">
<CheckIcon :size="20" />
<span>{{ strings.saveSuccess }}</span>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import CheckIcon from '@icons/Check.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
interface UserPreferences {
auto_subscribe_created_threads: boolean
}
export default defineComponent({
name: 'UserPreferencesView',
components: {
NcButton,
NcEmptyContent,
NcLoadingIcon,
NcCheckboxRadioSwitch,
AppToolbar,
PageWrapper,
PageHeader,
ArrowLeftIcon,
CheckIcon,
},
data() {
return {
loading: false,
saving: false,
saveSuccess: false,
error: null as string | null,
originalData: {
auto_subscribe_created_threads: true,
} as UserPreferences,
formData: {
auto_subscribe_created_threads: true,
} as UserPreferences,
strings: {
title: t('forum', 'Preferences'),
subtitle: t('forum', 'Customize your forum experience'),
back: t('forum', 'Back'),
loading: t('forum', 'Loading preferences…'),
errorTitle: t('forum', 'Error loading preferences'),
retry: t('forum', 'Retry'),
subscriptionsTitle: t('forum', 'Notifications'),
subscriptionsDesc: t('forum', 'Configure how you receive notifications'),
autoSubscribeLabel: t('forum', 'Auto-subscribe to threads I create'),
autoSubscribeHint: t(
'forum',
'When enabled, you will automatically receive notifications for replies to threads you create',
),
save: t('forum', 'Save'),
cancel: t('forum', 'Cancel'),
saveSuccess: t('forum', 'Preferences saved successfully'),
},
}
},
computed: {
hasChanges(): boolean {
return (
this.formData.auto_subscribe_created_threads !==
this.originalData.auto_subscribe_created_threads
)
},
},
created() {
this.loadPreferences()
},
methods: {
async loadPreferences(): Promise<void> {
try {
this.loading = true
this.error = null
const response = await ocs.get<UserPreferences>('/user-preferences')
this.originalData = { ...response.data }
this.formData = { ...response.data }
} catch (e) {
console.error('Failed to load preferences', e)
this.error = (e as Error).message || t('forum', 'An unexpected error occurred')
} finally {
this.loading = false
}
},
async savePreferences(): Promise<void> {
try {
this.saving = true
this.saveSuccess = false
await ocs.put('/user-preferences', this.formData)
this.originalData = { ...this.formData }
this.saveSuccess = true
// Hide success message after 3 seconds
setTimeout(() => {
this.saveSuccess = false
}, 3000)
} catch (e) {
console.error('Failed to save preferences', e)
this.error = (e as Error).message || t('forum', 'Failed to save preferences')
} finally {
this.saving = false
}
},
resetForm(): void {
this.formData = { ...this.originalData }
this.saveSuccess = false
},
goBack(): void {
this.$router.back()
},
},
})
</script>
<style scoped lang="scss">
.user-preferences-view {
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
.mt-16 {
margin-top: 16px;
}
.ml-8 {
margin-left: 8px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.page-header {
margin-bottom: 24px;
h2 {
margin: 0 0 6px 0;
}
}
.preferences-form {
.form-section {
margin-bottom: 32px;
padding: 24px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
h3 {
margin: 0 0 8px 0;
font-size: 1.1rem;
font-weight: 600;
}
.section-description {
margin: 0 0 20px 0;
font-size: 0.9rem;
}
}
.preference-item {
padding: 12px 0;
.preference-hint {
margin: 8px 0 0 32px;
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
line-height: 1.4;
}
}
.form-actions {
display: flex;
gap: 12px;
align-items: center;
}
.success-message {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 16px;
background: var(--color-success-light);
color: var(--color-success-dark);
border-radius: 6px;
font-weight: 500;
}
}
}
</style>

View File

@@ -1,255 +1,257 @@
<template>
<div class="admin-bbcode-list">
<div class="page-header">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
<PageWrapper>
<template #toolbar>
<AppToolbar>
<template #right>
<NcButton @click="showHelp = true">
<template #icon>
<HelpCircleIcon :size="20" />
</template>
{{ strings.help }}
</NcButton>
<NcButton variant="primary" @click="createBBCode">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createBBCode }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="admin-bbcode-list">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- BBCode Help Dialog -->
<BBCodeHelpDialog v-model:open="showHelp" :show-custom="false" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<div class="header-actions">
<NcButton @click="showHelp = true">
<template #icon>
<HelpCircleIcon :size="20" />
</template>
{{ strings.help }}
</NcButton>
<NcButton variant="primary" @click="createBBCode">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createBBCode }}
</NcButton>
</div>
</div>
<!-- BBCode Help Dialog -->
<BBCodeHelpDialog v-model:open="showHelp" :show-custom="false" />
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- BBCode list -->
<div v-else class="bbcode-list">
<!-- Enabled BBCodes Section -->
<section class="bbcodes-section">
<div class="section-header">
<h3>{{ strings.enabledTitle }}</h3>
<p class="muted">{{ strings.enabledSubtitle }}</p>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- BBCode list -->
<div v-else class="bbcode-list">
<!-- Enabled BBCodes Section -->
<section class="bbcodes-section">
<div class="section-header">
<h3>{{ strings.enabledTitle }}</h3>
<p class="muted">{{ strings.enabledSubtitle }}</p>
</div>
<div v-if="enabledBBCodes.length > 0" class="bbcodes-table">
<div v-for="bbcode in enabledBBCodes" :key="`bbcode-${bbcode.id}`" class="bbcode-row">
<div class="bbcode-info">
<div class="bbcode-header">
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
<div v-if="bbcode.parseInner" class="badge badge-info">
{{ strings.parseInner }}
<div v-if="enabledBBCodes.length > 0" class="bbcodes-table">
<div v-for="bbcode in enabledBBCodes" :key="`bbcode-${bbcode.id}`" class="bbcode-row">
<div class="bbcode-info">
<div class="bbcode-header">
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
<div v-if="bbcode.parseInner" class="badge badge-info">
{{ strings.parseInner }}
</div>
</div>
<div v-if="bbcode.description" class="bbcode-desc muted">
{{ bbcode.description }}
</div>
<div class="bbcode-replacement">
<span class="label muted">{{ strings.replacement }}:</span>
<code>{{ bbcode.replacement }}</code>
</div>
</div>
<div v-if="bbcode.description" class="bbcode-desc muted">
{{ bbcode.description }}
<div class="bbcode-actions">
<NcButton @click="editBBCode(bbcode)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton @click="toggleEnabled(bbcode)">
<template #icon>
<EyeOffIcon :size="20" />
</template>
{{ strings.disable }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(bbcode)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
<div class="bbcode-replacement">
<span class="label muted">{{ strings.replacement }}:</span>
<code>{{ bbcode.replacement }}</code>
</div>
</div>
<div class="bbcode-actions">
<NcButton @click="editBBCode(bbcode)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton @click="toggleEnabled(bbcode)">
<template #icon>
<EyeOffIcon :size="20" />
</template>
{{ strings.disable }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(bbcode)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
<div v-else class="no-bbcodes muted">
{{ strings.noEnabledBBCodes }}
</div>
</section>
<div v-else class="no-bbcodes muted">
{{ strings.noEnabledBBCodes }}
</div>
</section>
<!-- Disabled BBCodes Section -->
<section v-if="disabledBBCodes.length > 0" class="bbcodes-section">
<div class="section-header">
<h3>{{ strings.disabledTitle }}</h3>
<p class="muted">{{ strings.disabledSubtitle }}</p>
<!-- Disabled BBCodes Section -->
<section v-if="disabledBBCodes.length > 0" class="bbcodes-section">
<div class="section-header">
<h3>{{ strings.disabledTitle }}</h3>
<p class="muted">{{ strings.disabledSubtitle }}</p>
</div>
<div class="bbcodes-table">
<div
v-for="bbcode in disabledBBCodes"
:key="`bbcode-${bbcode.id}`"
class="bbcode-row disabled"
>
<div class="bbcode-info">
<div class="bbcode-header">
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
<div v-if="bbcode.parseInner" class="badge badge-info">
{{ strings.parseInner }}
</div>
</div>
<div v-if="bbcode.description" class="bbcode-desc muted">
{{ bbcode.description }}
</div>
<div class="bbcode-replacement">
<span class="label muted">{{ strings.replacement }}:</span>
<code>{{ bbcode.replacement }}</code>
</div>
</div>
<div class="bbcode-actions">
<NcButton @click="editBBCode(bbcode)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="primary" @click="toggleEnabled(bbcode)">
<template #icon>
<EyeIcon :size="20" />
</template>
{{ strings.enable }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(bbcode)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
</section>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.bbcode?.tag || '') }}</p>
<p class="muted">{{ strings.deleteWarning }}</p>
</div>
<div class="bbcodes-table">
<div
v-for="bbcode in disabledBBCodes"
:key="`bbcode-${bbcode.id}`"
class="bbcode-row disabled"
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="error" @click="executeDelete">
{{ strings.deleteBBCode }}
</NcButton>
</template>
</NcDialog>
<!-- BBCode Edit/Create Dialog -->
<NcDialog
v-if="editDialog.show"
:name="editDialog.isEditing ? strings.editBBCodeTitle : strings.createBBCodeTitle"
@close="editDialog.show = false"
>
<div class="bbcode-dialog-content">
<div class="form-group">
<NcTextField
v-model="editDialog.tag"
:label="strings.tag"
:placeholder="strings.tagPlaceholder"
:required="true"
/>
<p class="help-text muted">{{ strings.tagHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.replacement"
:label="strings.replacementLabel"
:placeholder="strings.replacementPlaceholder"
:rows="3"
:required="true"
/>
<p class="help-text muted">{{ strings.replacementHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.example"
:label="strings.exampleLabel"
:placeholder="strings.examplePlaceholder"
:rows="2"
:required="true"
/>
<p class="help-text muted">{{ strings.exampleHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcCheckboxRadioSwitch v-model="editDialog.enabled" type="switch">
{{ strings.enabledLabel }}
</NcCheckboxRadioSwitch>
</div>
<div class="form-group">
<NcCheckboxRadioSwitch v-model="editDialog.parseInner" type="switch">
{{ strings.parseInnerLabel }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.parseInnerHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="editDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="primary"
:disabled="
!editDialog.tag.trim() || !editDialog.replacement.trim() || !editDialog.example.trim()
"
@click="saveBBCode"
>
<div class="bbcode-info">
<div class="bbcode-header">
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
<div v-if="bbcode.parseInner" class="badge badge-info">
{{ strings.parseInner }}
</div>
</div>
<div v-if="bbcode.description" class="bbcode-desc muted">
{{ bbcode.description }}
</div>
<div class="bbcode-replacement">
<span class="label muted">{{ strings.replacement }}:</span>
<code>{{ bbcode.replacement }}</code>
</div>
</div>
<div class="bbcode-actions">
<NcButton @click="editBBCode(bbcode)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="primary" @click="toggleEnabled(bbcode)">
<template #icon>
<EyeIcon :size="20" />
</template>
{{ strings.enable }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(bbcode)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
</section>
<template v-if="editDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ editDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.bbcode?.tag || '') }}</p>
<p class="muted">{{ strings.deleteWarning }}</p>
</div>
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="error" @click="executeDelete">
{{ strings.deleteBBCode }}
</NcButton>
</template>
</NcDialog>
<!-- BBCode Edit/Create Dialog -->
<NcDialog
v-if="editDialog.show"
:name="editDialog.isEditing ? strings.editBBCodeTitle : strings.createBBCodeTitle"
@close="editDialog.show = false"
>
<div class="bbcode-dialog-content">
<div class="form-group">
<NcTextField
v-model="editDialog.tag"
:label="strings.tag"
:placeholder="strings.tagPlaceholder"
:required="true"
/>
<p class="help-text muted">{{ strings.tagHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.replacement"
:label="strings.replacementLabel"
:placeholder="strings.replacementPlaceholder"
:rows="3"
:required="true"
/>
<p class="help-text muted">{{ strings.replacementHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.example"
:label="strings.exampleLabel"
:placeholder="strings.examplePlaceholder"
:rows="2"
:required="true"
/>
<p class="help-text muted">{{ strings.exampleHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcCheckboxRadioSwitch v-model="editDialog.enabled" type="switch">
{{ strings.enabledLabel }}
</NcCheckboxRadioSwitch>
</div>
<div class="form-group">
<NcCheckboxRadioSwitch v-model="editDialog.parseInner" type="switch">
{{ strings.parseInnerLabel }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.parseInnerHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="editDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="primary"
:disabled="
!editDialog.tag.trim() || !editDialog.replacement.trim() || !editDialog.example.trim()
"
@click="saveBBCode"
>
<template v-if="editDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ editDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -268,6 +270,9 @@ import EyeIcon from '@icons/Eye.vue'
import EyeOffIcon from '@icons/EyeOff.vue'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import BBCodeHelpDialog from '@/components/BBCodeHelpDialog.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import AppToolbar from '@/components/AppToolbar.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
@@ -292,6 +297,9 @@ export default defineComponent({
NcLoadingIcon,
NcTextField,
NcTextArea,
PageWrapper,
PageHeader,
AppToolbar,
PlusIcon,
PencilIcon,
DeleteIcon,
@@ -496,8 +504,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-bbcode-list {
max-width: 1200px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
@@ -517,22 +523,6 @@ export default defineComponent({
justify-content: center;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
h2 {
margin: 0 0 6px 0;
}
.header-actions {
display: flex;
gap: 8px;
}
}
.bbcode-list {
display: flex;
flex-direction: column;

View File

@@ -1,192 +1,199 @@
<template>
<div class="admin-category-edit">
<div class="page-header">
<div class="header-actions">
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
<PageWrapper>
<template #toolbar>
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="admin-category-edit">
<PageHeader
:title="isEditing ? strings.editCategory : strings.createCategory"
:subtitle="strings.subtitle"
/>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<div>
<h2>{{ isEditing ? strings.editCategory : strings.createCategory }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Form -->
<div v-else class="category-form">
<section class="form-section">
<h3>{{ strings.basicInfo }}</h3>
<div class="form-grid">
<div class="form-group">
<label>{{ strings.categoryHeader }} *</label>
<div class="header-select-row">
<NcSelect
v-model="selectedHeader"
:options="headerOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
class="header-select"
/>
<NcButton @click="createNewHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.newHeader }}
</NcButton>
<NcButton v-if="selectedHeader" @click="editHeader">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editHeader }}
</NcButton>
</div>
</div>
<!-- Form -->
<div v-else class="category-form">
<section class="form-section">
<h3>{{ strings.basicInfo }}</h3>
<div class="form-grid">
<div class="form-group">
<label>{{ strings.categoryHeader }} *</label>
<div class="header-select-row">
<NcSelect
v-model="selectedHeader"
:options="headerOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
class="header-select"
<div class="form-group">
<NcTextField
v-model="formData.name"
:label="strings.name"
:placeholder="strings.namePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextField
v-model="formData.slug"
:label="strings.slug"
:placeholder="strings.slugPlaceholder"
:required="true"
/>
<p class="help-text muted">{{ strings.slugHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
/>
<NcButton @click="createNewHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.newHeader }}
</NcButton>
<NcButton v-if="selectedHeader" @click="editHeader">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editHeader }}
</NcButton>
</div>
</div>
</section>
<div class="form-group">
<NcTextField
v-model="formData.name"
:label="strings.name"
:placeholder="strings.namePlaceholder"
:required="true"
/>
<!-- Permissions Section -->
<section class="form-section">
<h3>{{ strings.permissions }}</h3>
<p class="muted">{{ strings.permissionsDescription }}</p>
<div class="form-grid">
<div class="form-group">
<label>{{ strings.viewRoles }}</label>
<NcSelect
v-model="selectedViewRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.viewRolesHelp }}</p>
</div>
<div class="form-group">
<label>{{ strings.moderateRoles }}</label>
<NcSelect
v-model="selectedModerateRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.moderateRolesHelp }}</p>
</div>
</div>
</section>
<!-- Actions -->
<div class="form-actions">
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
<template v-if="submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ isEditing ? strings.update : strings.create }}
</NcButton>
</div>
</div>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="formData.slug"
:label="strings.slug"
:placeholder="strings.slugPlaceholder"
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
<p class="help-text muted">{{ strings.slugHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
</div>
</section>
<!-- Permissions Section -->
<section class="form-section">
<h3>{{ strings.permissions }}</h3>
<p class="muted">{{ strings.permissionsDescription }}</p>
<div class="form-grid">
<div class="form-group">
<label>{{ strings.viewRoles }}</label>
<NcSelect
v-model="selectedViewRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.viewRolesHelp }}</p>
</div>
<div class="form-group">
<label>{{ strings.moderateRoles }}</label>
<NcSelect
v-model="selectedModerateRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.moderateRolesHelp }}</p>
</div>
</div>
</section>
<!-- Actions -->
<div class="form-actions">
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
<template v-if="submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ isEditing ? strings.update : strings.create }}
</NcButton>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import PageWrapper from '@/components/PageWrapper.vue'
import AppToolbar from '@/components/AppToolbar.vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
@@ -211,6 +218,8 @@ export default defineComponent({
NcSelect,
NcTextField,
NcTextArea,
PageWrapper,
AppToolbar,
ArrowLeftIcon,
PlusIcon,
PencilIcon,
@@ -231,6 +240,7 @@ export default defineComponent({
slug: '',
description: '',
},
slugManuallyEdited: false,
headerDialog: {
show: false,
isEditing: false,
@@ -320,11 +330,35 @@ export default defineComponent({
selectedHeader(newVal: { id: number; label: string } | null) {
this.formData.headerId = newVal?.id || null
},
'formData.name'(newVal: string) {
// Only auto-update slug when creating (not editing) and user hasn't manually edited it
if (!this.isEditing && !this.slugManuallyEdited) {
this.formData.slug = this.toKebabCase(newVal)
}
},
'formData.slug'(newVal: string, oldVal: string) {
// Only track manual edits when creating (not when editing existing category)
if (!this.isEditing && newVal !== oldVal && newVal !== this.toKebabCase(this.formData.name)) {
this.slugManuallyEdited = true
}
if (!newVal) {
this.slugManuallyEdited = false
}
},
},
created() {
this.refresh()
},
methods: {
toKebabCase(str: string): string {
return str
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
},
async refresh(): Promise<void> {
try {
this.loading = true
@@ -342,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)
@@ -362,6 +414,9 @@ export default defineComponent({
this.formData.slug = category.slug
this.formData.description = category.description || ''
// When editing, don't track manual slug edits (slug is pre-populated from DB)
this.slugManuallyEdited = false
// Set selectedHeader based on headerId
const header = this.headers.find((h) => h.id === category.headerId)
if (header) {
@@ -559,8 +614,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-category-edit {
max-width: 800px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
@@ -583,10 +636,6 @@ export default defineComponent({
.page-header {
margin-bottom: 24px;
.header-actions {
margin-bottom: 12px;
}
h2 {
margin: 0 0 6px 0;
}

View File

@@ -1,112 +1,56 @@
<template>
<div class="admin-category-list">
<div class="page-header">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
<PageWrapper>
<template #toolbar>
<AppToolbar>
<template #right>
<NcButton @click="createHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createHeader }}
</NcButton>
<NcButton variant="primary" @click="createCategory">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createCategory }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="admin-category-list">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<div class="header-actions">
<NcButton @click="createHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createHeader }}
</NcButton>
<NcButton variant="primary" @click="createCategory">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createCategory }}
</NcButton>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Category list -->
<div v-else class="category-list">
<!-- Categories by Header -->
<section class="categories-section">
<template v-for="(header, headerIndex) in categoryHeaders" :key="header.id">
<div class="header-row">
<div class="header-sort-buttons">
<NcButton
v-if="headerIndex > 0"
variant="tertiary"
@click="moveHeaderUp(headerIndex)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="headerIndex < categoryHeaders.length - 1"
variant="tertiary"
@click="moveHeaderDown(headerIndex)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="header-info">
<h3>{{ header.name }}</h3>
<span v-if="header.description" class="muted">{{ header.description }}</span>
<span class="muted category-count"
>{{ header.categories?.length || 0 }} {{ strings.categoriesCount }}</span
>
</div>
<div class="header-actions">
<NcButton @click="editHeaderById(header.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton
variant="error"
:disabled="categoryHeaders.length <= 1"
@click="confirmDeleteHeader(header)"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div
v-for="(category, index) in header.categories"
:key="category.id"
class="category-row"
>
<div class="category-sort-buttons">
<!-- Category list -->
<div v-else class="category-list">
<!-- Categories by Header -->
<section class="categories-section">
<template v-for="(header, headerIndex) in categoryHeaders" :key="header.id">
<div class="header-row">
<div class="header-sort-buttons">
<NcButton
v-if="index > 0"
v-if="headerIndex > 0"
variant="tertiary"
@click="moveCategoryUp(header.id, index)"
@click="moveHeaderUp(headerIndex)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
@@ -115,9 +59,9 @@
</template>
</NcButton>
<NcButton
v-if="index < header.categories.length - 1"
v-if="headerIndex < categoryHeaders.length - 1"
variant="tertiary"
@click="moveCategoryDown(header.id, index)"
@click="moveHeaderDown(headerIndex)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
@@ -126,27 +70,25 @@
</template>
</NcButton>
</div>
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">
{{ category.description }}
</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>Threads: {{ category.threadCount || 0 }}</span>
<span></span>
<span>Posts: {{ category.postCount || 0 }}</span>
</div>
<div class="header-info">
<h3>{{ header.name }}</h3>
<span v-if="header.description" class="muted">{{ header.description }}</span>
<span class="muted category-count"
>{{ header.categories?.length || 0 }} {{ strings.categoriesCount }}</span
>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<div class="header-actions">
<NcButton @click="editHeaderById(header.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(category)">
<NcButton
variant="error"
:disabled="categoryHeaders.length <= 1"
@click="confirmDeleteHeader(header)"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
@@ -154,201 +96,272 @@
</NcButton>
</div>
</div>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
</div>
</template>
</section>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
<div v-if="deleteDialog.threadCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.threadWarning(deleteDialog.threadCount) }}</span>
</div>
<div v-if="deleteDialog.threadCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithThreads }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="migrate"
type="radio"
name="delete-action"
>
{{ strings.migrateThreads }}
</NcCheckboxRadioSwitch>
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetCategory }}</label>
<NcSelect
v-model="selectedTargetCategory"
:options="targetCategoryOptions"
:placeholder="strings.selectCategory"
label="label"
track-by="id"
/>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div
v-for="(category, index) in header.categories"
:key="category.id"
class="category-row"
>
<div class="category-sort-buttons">
<NcButton
v-if="index > 0"
variant="tertiary"
@click="moveCategoryUp(header.id, index)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="index < header.categories.length - 1"
variant="tertiary"
@click="moveCategoryDown(header.id, index)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">
{{ category.description }}
</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>Threads: {{ category.threadCount || 0 }}</span>
<span></span>
<span>Posts: {{ category.postCount || 0 }}</span>
</div>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(category)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="delete"
type="radio"
name="delete-action"
>
{{ strings.softDeleteThreads }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="deleteDialog.action === 'migrate' && !selectedTargetCategory"
@click="executeDelete"
>
{{ strings.deleteCategory }}
</NcButton>
</template>
</NcDialog>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcTextField
v-model.number="headerDialog.sortOrder"
:label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
<!-- Header Delete Confirmation Dialog -->
<NcDialog
v-if="deleteHeaderDialog.show"
:name="strings.deleteHeaderTitle"
@close="deleteHeaderDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.headerCategoryWarning(deleteHeaderDialog.categoryCount) }}</span>
</div>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithCategories }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="migrate"
type="radio"
name="delete-header-action"
>
{{ strings.migrateCategories }}
</NcCheckboxRadioSwitch>
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetHeader }}</label>
<NcSelect
v-model="selectedTargetHeader"
:options="targetHeaderOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="delete"
type="radio"
name="delete-header-action"
>
{{ strings.deleteCategories }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
</div>
</div>
</section>
</div>
<template #actions>
<NcButton @click="deleteHeaderDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="deleteHeaderDialog.action === 'migrate' && !selectedTargetHeader"
@click="executeDeleteHeader"
>
{{ strings.deleteHeader }}
</NcButton>
</template>
</NcDialog>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
<div v-if="deleteDialog.threadCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.threadWarning(deleteDialog.threadCount) }}</span>
</div>
<div v-if="deleteDialog.threadCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithThreads }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="migrate"
type="radio"
name="delete-action"
>
{{ strings.migrateThreads }}
</NcCheckboxRadioSwitch>
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetCategory }}</label>
<NcSelect
v-model="selectedTargetCategory"
:options="targetCategoryOptions"
:placeholder="strings.selectCategory"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="delete"
type="radio"
name="delete-action"
>
{{ strings.softDeleteThreads }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="
deleteDialog.action === 'migrate' &&
deleteDialog.threadCount > 0 &&
!selectedTargetCategory
"
@click="executeDelete"
>
{{ strings.deleteCategory }}
</NcButton>
</template>
</NcDialog>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcTextField
v-model.number="headerDialog.sortOrder"
:label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
<!-- Header Delete Confirmation Dialog -->
<NcDialog
v-if="deleteHeaderDialog.show"
:name="strings.deleteHeaderTitle"
@close="deleteHeaderDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.headerCategoryWarning(deleteHeaderDialog.categoryCount) }}</span>
</div>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithCategories }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="migrate"
type="radio"
name="delete-header-action"
>
{{ strings.migrateCategories }}
</NcCheckboxRadioSwitch>
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetHeader }}</label>
<NcSelect
v-model="selectedTargetHeader"
:options="targetHeaderOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="delete"
type="radio"
name="delete-header-action"
>
{{ strings.deleteCategories }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteHeaderDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="
deleteHeaderDialog.action === 'migrate' &&
deleteHeaderDialog.categoryCount > 0 &&
!selectedTargetHeader
"
@click="executeDeleteHeader"
>
{{ strings.deleteHeader }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import AppToolbar from '@/components/AppToolbar.vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog'
@@ -370,6 +383,9 @@ import type { CategoryHeader, Category, CatHeader } from '@/types'
export default defineComponent({
name: 'AdminCategoryList',
components: {
PageWrapper,
PageHeader,
AppToolbar,
NcButton,
NcCheckboxRadioSwitch,
NcDialog,
@@ -753,8 +769,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-category-list {
max-width: 1200px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
@@ -774,27 +788,10 @@ export default defineComponent({
justify-content: center;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
h2 {
margin: 0 0 6px 0;
}
.header-actions {
display: flex;
gap: 8px;
}
}
.category-list {
.categories-section {
display: flex;
flex-direction: column;
gap: 32px;
}
.header-row {
@@ -807,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,135 +1,138 @@
<template>
<div class="admin-dashboard">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<PageWrapper>
<div class="admin-dashboard">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Dashboard content -->
<div v-else-if="stats" class="dashboard-content">
<!-- Totals section -->
<section class="stats-section">
<h3>{{ strings.totals }}</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<AccountMultipleIcon :size="32" />
<!-- Dashboard content -->
<div v-else-if="stats" class="dashboard-content">
<!-- Totals section -->
<section class="stats-section">
<h3>{{ strings.totals }}</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<AccountMultipleIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.users }}</div>
<div class="stat-label">{{ strings.totalUsers }}</div>
</div>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.users }}</div>
<div class="stat-label">{{ strings.totalUsers }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<ForumIcon :size="32" />
<div class="stat-card">
<div class="stat-icon">
<ForumIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.threads }}</div>
<div class="stat-label">{{ strings.totalThreads }}</div>
</div>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.threads }}</div>
<div class="stat-label">{{ strings.totalThreads }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<MessageTextIcon :size="32" />
<div class="stat-card">
<div class="stat-icon">
<MessageTextIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.posts }}</div>
<div class="stat-label">{{ strings.totalPosts }}</div>
</div>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.posts }}</div>
<div class="stat-label">{{ strings.totalPosts }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<FolderIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.categories }}</div>
<div class="stat-label">{{ strings.totalCategories }}</div>
</div>
</div>
</div>
</section>
<!-- Recent activity section -->
<section class="stats-section mt-24">
<h3>{{ strings.recentActivity }}</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<AccountPlusIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.users }}</div>
<div class="stat-label">{{ strings.newUsers }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<ForumIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.threads }}</div>
<div class="stat-label">{{ strings.newThreads }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<MessageTextIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.posts }}</div>
<div class="stat-label">{{ strings.newPosts }}</div>
</div>
</div>
</div>
</section>
<!-- Top contributors section -->
<section class="stats-section mt-24">
<h3>{{ strings.topContributors }}</h3>
<div v-if="stats.topContributors.length > 0" class="contributors-list">
<div
v-for="(contributor, index) in stats.topContributors"
:key="contributor.userId"
class="contributor-item"
>
<div class="contributor-rank">{{ index + 1 }}</div>
<NcAvatar :user="contributor.userId" :size="40" />
<div class="contributor-info">
<div class="contributor-name">{{ contributor.userId }}</div>
<div class="contributor-stats muted">
{{ strings.postsCount(contributor.postCount) }}
<div class="stat-card">
<div class="stat-icon">
<FolderIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.categories }}</div>
<div class="stat-label">{{ strings.totalCategories }}</div>
</div>
</div>
</div>
</div>
<div v-else class="muted">{{ strings.noContributors }}</div>
</section>
</section>
<!-- Recent activity section -->
<section class="stats-section mt-24">
<h3>{{ strings.recentActivity }}</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<AccountPlusIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.users }}</div>
<div class="stat-label">{{ strings.newUsers }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<ForumIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.threads }}</div>
<div class="stat-label">{{ strings.newThreads }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<MessageTextIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.posts }}</div>
<div class="stat-label">{{ strings.newPosts }}</div>
</div>
</div>
</div>
</section>
<!-- Top contributors section -->
<section class="stats-section mt-24">
<h3>{{ strings.topContributors }}</h3>
<div v-if="stats.topContributors.length > 0" class="contributors-list">
<div
v-for="(contributor, index) in stats.topContributors"
:key="contributor.userId"
class="contributor-item"
>
<div class="contributor-rank">{{ index + 1 }}</div>
<UserInfo
:user-id="contributor.userId"
:display-name="contributor.userId"
:avatar-size="40"
>
<template #meta>
<div class="contributor-stats muted">
{{ strings.postsCount(contributor.postCount) }}
</div>
</template>
</UserInfo>
</div>
</div>
<div v-else class="muted">{{ strings.noContributors }}</div>
</section>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -137,7 +140,9 @@ import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import UserInfo from '@/components/UserInfo.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import AccountMultipleIcon from '@icons/AccountMultiple.vue'
import AccountPlusIcon from '@icons/AccountPlus.vue'
import ForumIcon from '@icons/Forum.vue'
@@ -170,7 +175,9 @@ export default defineComponent({
NcButton,
NcEmptyContent,
NcLoadingIcon,
NcAvatar,
UserInfo,
PageWrapper,
PageHeader,
AccountMultipleIcon,
AccountPlusIcon,
ForumIcon,
@@ -346,18 +353,9 @@ export default defineComponent({
font-size: 0.9rem;
}
.contributor-info {
flex: 1;
.contributor-name {
font-weight: 500;
color: var(--color-main-text);
}
.contributor-stats {
font-size: 0.85rem;
margin-top: 2px;
}
.contributor-stats {
font-size: 0.85rem;
margin-top: 2px;
}
}
}

View File

@@ -1,79 +1,78 @@
<template>
<div class="admin-general-settings">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<PageWrapper>
<div class="admin-general-settings">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="loadSettings">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="loadSettings">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Settings form -->
<div v-else class="settings-form">
<div class="form-section">
<h3>{{ strings.appearanceTitle }}</h3>
<p class="muted">{{ strings.appearanceDesc }}</p>
<!-- Settings form -->
<div v-else class="settings-form">
<div class="form-section">
<h3>{{ strings.appearanceTitle }}</h3>
<p class="muted">{{ strings.appearanceDesc }}</p>
<div class="form-group">
<label for="forum-title">{{ strings.forumTitle }}</label>
<NcTextField
id="forum-title"
v-model.trim="formData.title"
:placeholder="strings.forumTitlePlaceholder"
:maxlength="100"
/>
<p class="hint">{{ strings.forumTitleHint }}</p>
<div class="form-group">
<label for="forum-title">{{ strings.forumTitle }}</label>
<NcTextField
id="forum-title"
v-model.trim="formData.title"
:placeholder="strings.forumTitlePlaceholder"
:maxlength="100"
/>
<p class="hint">{{ strings.forumTitleHint }}</p>
</div>
<div class="form-group">
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
<NcTextArea
id="forum-subtitle"
v-model.trim="formData.subtitle"
:placeholder="strings.forumSubtitlePlaceholder"
:rows="3"
:maxlength="500"
/>
<p class="hint">{{ strings.forumSubtitleHint }}</p>
</div>
</div>
<div class="form-group">
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
<NcTextArea
id="forum-subtitle"
v-model.trim="formData.subtitle"
:placeholder="strings.forumSubtitlePlaceholder"
:rows="3"
:maxlength="500"
/>
<p class="hint">{{ strings.forumSubtitleHint }}</p>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="saveSettings">
<template #icon>
<NcLoadingIcon v-if="saving" :size="20" />
<CheckIcon v-else :size="20" />
</template>
{{ strings.save }}
</NcButton>
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
{{ strings.cancel }}
</NcButton>
</div>
<!-- Success message -->
<div v-if="saveSuccess" class="success-message">
<CheckIcon :size="20" />
<span>{{ strings.saveSuccess }}</span>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="saveSettings">
<template #icon>
<NcLoadingIcon v-if="saving" :size="20" />
<CheckIcon v-else :size="20" />
</template>
{{ strings.save }}
</NcButton>
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
{{ strings.cancel }}
</NcButton>
</div>
<!-- Success message -->
<div v-if="saveSuccess" class="success-message">
<CheckIcon :size="20" />
<span>{{ strings.saveSuccess }}</span>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -83,6 +82,8 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import CheckIcon from '@icons/Check.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
@@ -100,6 +101,8 @@ export default defineComponent({
NcLoadingIcon,
NcTextField,
NcTextArea,
PageHeader,
PageWrapper,
CheckIcon,
},
data() {
@@ -225,7 +228,6 @@ export default defineComponent({
}
.settings-form {
max-width: 800px;
.form-section {
margin-bottom: 32px;

View File

@@ -1,163 +1,168 @@
<template>
<div class="admin-role-edit">
<div class="page-header">
<div class="header-actions">
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
<PageWrapper>
<template #toolbar>
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="admin-role-edit">
<PageHeader
:title="isEditing ? strings.editRole : strings.createRole"
:subtitle="strings.subtitle"
/>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<div>
<h2>{{ isEditing ? strings.editRole : strings.createRole }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Form -->
<div v-else class="role-form">
<!-- Basic Info Section -->
<section class="form-section">
<h3>{{ strings.basicInfo }}</h3>
<div class="form-grid">
<div class="form-group">
<NcTextField
v-model="formData.name"
:label="strings.name"
:placeholder="strings.namePlaceholder"
:disabled="isSystemRole"
:required="true"
/>
<p v-if="isSystemRole" class="help-text muted">
{{ strings.systemRoleNameWarning }}
</p>
</div>
<div class="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
/>
</div>
</div>
</section>
<!-- Role Permissions Section -->
<section class="form-section">
<h3>{{ strings.rolePermissions }}</h3>
<p class="muted">{{ strings.rolePermissionsDesc }}</p>
<div class="permissions-checkboxes">
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools">
<strong>{{ strings.canAccessAdminTools }}</strong>
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditRoles">
<strong>{{ strings.canEditRoles }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditRolesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditCategories">
<strong>{{ strings.canEditCategories }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
</div>
</section>
<!-- Category Permissions Section -->
<section class="form-section">
<h3>{{ strings.categoryPermissions }}</h3>
<p v-if="isAdmin" class="info-message">
<InformationIcon :size="20" />
{{ strings.adminFullAccess }}
</p>
<p v-else class="muted">{{ strings.categoryPermissionsDesc }}</p>
<div v-if="categoryHeaders.length > 0" class="permissions-table">
<div class="table-header">
<div class="col-category">{{ strings.category }}</div>
<div class="col-permission">{{ strings.canView }}</div>
<div class="col-permission">{{ strings.canModerate }}</div>
</div>
<template v-for="header in categoryHeaders" :key="`header-${header.id}`">
<!-- Header row -->
<div class="table-header-row">
<div class="header-name">{{ header.name }}</div>
<!-- Form -->
<div v-else class="role-form">
<!-- Basic Info Section -->
<section class="form-section">
<h3>{{ strings.basicInfo }}</h3>
<div class="form-grid">
<div class="form-group">
<NcTextField
v-model="formData.name"
:label="strings.name"
:placeholder="strings.namePlaceholder"
:disabled="isSystemRole"
:required="true"
/>
<p v-if="isSystemRole" class="help-text muted">
{{ strings.systemRoleNameWarning }}
</p>
</div>
<!-- Category rows under this header -->
<div v-for="category in header.categories" :key="category.id" class="table-row">
<div class="col-category">
<span class="category-name">{{ category.name }}</span>
<span v-if="category.description" class="category-desc muted">
{{ category.description }}
</span>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canView"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canModerate"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
/>
</div>
</template>
</div>
<div v-else class="muted">{{ strings.noCategories }}</div>
</section>
</div>
</section>
<!-- Actions -->
<div class="form-actions">
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
<template v-if="submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ isEditing ? strings.update : strings.create }}
</NcButton>
<!-- Role Permissions Section -->
<section class="form-section">
<h3>{{ strings.rolePermissions }}</h3>
<p class="muted">{{ strings.rolePermissionsDesc }}</p>
<div class="permissions-checkboxes">
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools">
<strong>{{ strings.canAccessAdminTools }}</strong>
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditRoles">
<strong>{{ strings.canEditRoles }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditRolesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditCategories">
<strong>{{ strings.canEditCategories }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
</div>
</section>
<!-- Category Permissions Section -->
<section class="form-section">
<h3>{{ strings.categoryPermissions }}</h3>
<p v-if="isAdmin" class="info-message">
<InformationIcon :size="20" />
{{ strings.adminFullAccess }}
</p>
<p v-else class="muted">{{ strings.categoryPermissionsDesc }}</p>
<div v-if="categoryHeaders.length > 0" class="permissions-table">
<div class="table-header">
<div class="col-category">{{ strings.category }}</div>
<div class="col-permission">{{ strings.canView }}</div>
<div class="col-permission">{{ strings.canModerate }}</div>
</div>
<template v-for="header in categoryHeaders" :key="`header-${header.id}`">
<!-- Header row -->
<div class="table-header-row">
<div class="header-name">{{ header.name }}</div>
</div>
<!-- Category rows under this header -->
<div v-for="category in header.categories" :key="category.id" class="table-row">
<div class="col-category">
<span class="category-name">{{ category.name }}</span>
<span v-if="category.description" class="category-desc muted">
{{ category.description }}
</span>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canView"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canModerate"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
</div>
</template>
</div>
<div v-else class="muted">{{ strings.noCategories }}</div>
</section>
<!-- Actions -->
<div class="form-actions">
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
<template v-if="submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ isEditing ? strings.update : strings.create }}
</NcButton>
</div>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -170,6 +175,9 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import InformationIcon from '@icons/Information.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import AppToolbar from '@/components/AppToolbar.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role, CategoryHeader } from '@/types'
@@ -188,8 +196,11 @@ export default defineComponent({
NcLoadingIcon,
NcTextField,
NcTextArea,
PageHeader,
ArrowLeftIcon,
InformationIcon,
PageWrapper,
AppToolbar,
},
data() {
return {
@@ -429,8 +440,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-role-edit {
max-width: 1200px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
@@ -453,10 +462,6 @@ export default defineComponent({
.page-header {
margin-bottom: 24px;
.header-actions {
margin-bottom: 12px;
}
h2 {
margin: 0 0 6px 0;
}

View File

@@ -1,104 +1,101 @@
<template>
<div class="admin-role-list">
<div class="page-header">
<div class="header-content">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<NcButton @click="createRole" variant="primary">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
<PageWrapper :full-width="true">
<template #toolbar>
<AppToolbar>
<template #right>
<NcButton @click="createRole" variant="primary">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="admin-role-list">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Role list -->
<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>
<template #cell-name="{ row }">
<span class="role-name" :class="getRoleClass(row.id)">{{ row.name }}</span>
</template>
<template #cell-description="{ row }">
<span v-if="row.description" class="role-description">{{ row.description }}</span>
<span v-else class="muted">{{ strings.noDescription }}</span>
</template>
<template #cell-created="{ row }">
<NcDateTime :timestamp="row.createdAt * 1000" />
</template>
<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
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createRole">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</template>
</NcEmptyContent>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Role list -->
<div v-else-if="roles.length > 0" class="roles-content">
<div class="roles-table">
<div class="table-header">
<div class="col-id">{{ strings.id }}</div>
<div class="col-name">{{ strings.name }}</div>
<div class="col-description">{{ strings.description }}</div>
<div class="col-created">{{ strings.created }}</div>
<div class="col-actions">{{ strings.actions }}</div>
</div>
<div v-for="role in roles" :key="role.id" class="table-row">
<div class="col-id">
<span class="role-id">{{ role.id }}</span>
</div>
<div class="col-name">
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
</div>
<div class="col-description">
<span v-if="role.description" class="role-description">{{ role.description }}</span>
<span v-else class="muted">{{ strings.noDescription }}</span>
</div>
<div class="col-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>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createRole">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</template>
</NcEmptyContent>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -109,9 +106,13 @@ 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'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import AppToolbar from '@/components/AppToolbar.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role } from '@/types'
@@ -125,9 +126,13 @@ export default defineComponent({
NcDateTime,
NcActions,
NcActionButton,
AdminTable,
PlusIcon,
PencilIcon,
DeleteIcon,
PageWrapper,
PageHeader,
AppToolbar,
},
data() {
return {
@@ -162,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()
},
@@ -248,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

@@ -1,158 +1,150 @@
<template>
<div class="admin-user-list">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<PageWrapper :full-width="true">
<div class="admin-user-list">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- User list -->
<div v-else-if="users.length > 0" class="users-content">
<div class="users-table">
<div class="table-header">
<div class="col-user">{{ strings.user }}</div>
<div class="col-posts">{{ strings.posts }}</div>
<div class="col-roles">{{ strings.roles }}</div>
<div class="col-joined">{{ strings.joined }}</div>
<div class="col-status">{{ strings.status }}</div>
</div>
<div
v-for="user in users"
:key="user.userId"
class="table-row"
:class="{ 'is-deleted': user.isDeleted }"
>
<div class="col-user">
<NcAvatar :user="user.userId" :size="40" />
<div class="user-info">
<div class="user-name">{{ user.displayName }}</div>
<div class="user-id muted">@{{ user.userId }}</div>
</div>
</div>
<div class="col-posts">
<div class="post-stats">
<div class="stat-item">
<span class="stat-value">{{ user.threadCount }}</span>
<span class="stat-label muted">threads</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ user.postCount }}</span>
<span class="stat-label muted">posts</span>
</div>
</div>
</div>
<div class="col-roles">
<div v-if="editingUserId === user.userId" class="roles-editor">
<NcSelect
v-model="editingRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
:multiple="true"
label="name"
track-by="id"
input-label="name"
class="roles-select"
/>
<div class="edit-actions">
<NcButton @click="cancelEdit" :aria-label="strings.cancel" :title="strings.cancel">
<template #icon>
<CloseIcon :size="20" />
</template>
</NcButton>
<NcButton
variant="primary"
@click="saveRoles(user.userId)"
:aria-label="strings.save"
:title="strings.save"
>
<template #icon>
<CheckIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div v-else class="roles-display">
<div class="roles-list">
<span
v-for="roleId in user.roles"
:key="roleId"
class="role-badge"
:class="getRoleBadgeClass(roleId)"
>
{{ getRoleName(roleId) }}
</span>
<span v-if="user.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
</div>
<NcButton
@click="startEdit(user.userId, user.roles)"
:aria-label="strings.edit"
:title="strings.edit"
>
<template #icon>
<PencilIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div class="col-joined">
<NcDateTime :timestamp="user.createdAt * 1000" />
</div>
<div class="col-status">
<span v-if="user.isDeleted" class="status-badge status-deleted">
{{ strings.deleted }}
</span>
<span v-else class="status-badge status-active">
{{ strings.active }}
</span>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- User list -->
<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>
<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="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ row.postCount }}</span>
<span class="stat-label muted">posts</span>
</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
v-else
:title="strings.emptyTitle"
: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>
<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 NcAvatar from '@nextcloud/vue/components/NcAvatar'
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'
import { t } from '@nextcloud/l10n'
import type { Role } from '@/types'
@@ -178,14 +170,18 @@ export default defineComponent({
name: 'AdminUserList',
components: {
NcButton,
NcActions,
NcActionButton,
NcEmptyContent,
NcLoadingIcon,
NcAvatar,
NcDateTime,
NcSelect,
NcDialog,
UserInfo,
AdminTable,
PageWrapper,
PageHeader,
PencilIcon,
CheckIcon,
CloseIcon,
},
data() {
return {
@@ -210,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'),
},
@@ -227,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()
@@ -349,183 +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 {
display: flex;
align-items: center;
gap: 12px;
&.status-deleted {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
.user-info {
display: flex;
flex-direction: column;
gap: 2px;
.edit-roles-dialog {
padding: 16px 0;
.user-name {
font-weight: 500;
color: var(--color-main-text);
}
.user-id {
font-size: 0.85rem;
}
}
}
.col-posts {
.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;
}
}
}
.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.1.7
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'
},
},
},