mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5391d8fffe | |||
| b0bfbbccdf | |||
| 9525ebfb97 | |||
| 67e9fb9f8c | |||
| a36da9f882 | |||
| c0762158d7 | |||
| 479cdbbba5 | |||
| 255a5cf53d | |||
| feeefa2926 | |||
| f49561ccca | |||
| e59a6f4dc7 | |||
| 9719f518e2 | |||
| 2d10b461c0 | |||
| 2264289b56 | |||
| 3ef545dcc9 | |||
| fb905f8d15 | |||
| 278f1b3cc4 | |||
| 5ee8a16aa1 | |||
| a1671baf2d | |||
| 71ee133ac6 | |||
| 1add8db287 | |||
| e1e3ede1d8 | |||
| 9833e51997 | |||
| 664ee53670 | |||
| 7a80c19613 | |||
| 8cc34d9d7a | |||
| 364226fdc8 | |||
| 11aa3af887 | |||
| 0de120f2bf | |||
| e590f73fc0 | |||
| 4ca6388923 | |||
| cdecdce9d1 | |||
| bf59b47b2a | |||
| 2fbe180d5e | |||
| d16288f237 | |||
| 6ba8034b75 | |||
| 860092d6a9 | |||
| 29311708a5 | |||
| 51bcf64213 | |||
| 4e6ba7cb28 | |||
| dfed1dd340 | |||
| 34853a9844 | |||
| ae5ed0cbd4 | |||
| f2adfa8389 | |||
| b8da663739 | |||
| 0b2efa576e | |||
| c884e82505 | |||
| d8883aa40a | |||
| 6b049bdda3 | |||
| d84dd62cf0 | |||
| 7574cb59c0 | |||
| e778163889 |
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.1.2"}
|
||||
{".":"0.4.0"}
|
||||
|
||||
100
CHANGELOG.md
100
CHANGELOG.md
@@ -1,5 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* autoload ([2931170](https://github.com/chenasraf/nextcloud-forum/commit/29311708a54fdc3f2f1538fcf068e795f1a3a0c9))
|
||||
* update tar build tar ([51bcf64](https://github.com/chenasraf/nextcloud-forum/commit/51bcf6421341f73dc46602d0c2aab8842f3b12c6))
|
||||
|
||||
## [0.1.6](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.5...v0.1.6) (2025-11-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bbcode parsing source ([34853a9](https://github.com/chenasraf/nextcloud-forum/commit/34853a984486673cce0ae069f7d642c4c4e588ce))
|
||||
|
||||
## [0.1.5](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.4...v0.1.5) (2025-11-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* schema seed stage ([f2adfa8](https://github.com/chenasraf/nextcloud-forum/commit/f2adfa838945c39f676292fe2119db996b9387bb))
|
||||
|
||||
## [0.1.4](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.3...v0.1.4) (2025-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add app icon ([d8883aa](https://github.com/chenasraf/nextcloud-forum/commit/d8883aa40a419ceb93bf909b41f5e23ae008810e))
|
||||
* migration default values ([6b049bd](https://github.com/chenasraf/nextcloud-forum/commit/6b049bdda3016b437323202b1653f4db7a9b9dae))
|
||||
* migration seed step + admin user fetching ([0b2efa5](https://github.com/chenasraf/nextcloud-forum/commit/0b2efa576e2872c5a2115e45b537ea1bb7f63349))
|
||||
|
||||
## [0.1.3](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.2...v0.1.3) (2025-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* migration default values ([e778163](https://github.com/chenasraf/nextcloud-forum/commit/e77816388974a15c9fff8bcd2454872f3256618f))
|
||||
|
||||
## [0.1.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.1...v0.1.2) (2025-11-15)
|
||||
|
||||
|
||||
|
||||
16
Makefile
16
Makefile
@@ -30,7 +30,7 @@ app_name=forum
|
||||
repo_path=chenasraf/nextcloud-$(app_name)
|
||||
build_tools_directory=$(CURDIR)/build/tools
|
||||
source_build_directory=$(CURDIR)/build/artifacts/source
|
||||
source_intermediate_directory=$(CURDIR)/build/artifacts/intermediate-source
|
||||
source_intermediate_directory=$(CURDIR)/build/artifacts/intermediate-source/$(app_name)
|
||||
source_package_name=$(source_build_directory)/$(app_name)
|
||||
app_intermediate_directory=$(CURDIR)/build/artifacts/intermediate/$(app_name)
|
||||
appstore_build_directory=$(CURDIR)/build/artifacts/appstore
|
||||
@@ -147,14 +147,15 @@ source:
|
||||
--exclude="**/.git/**/*" \
|
||||
--exclude="build" \
|
||||
--exclude="tests" \
|
||||
--exclude="src" \
|
||||
--exclude="/src" \
|
||||
--exclude="js/node_modules" \
|
||||
--exclude="node_modules" \
|
||||
--exclude="*.log" \
|
||||
--exclude="dist/js/*.log" \
|
||||
--exclude="rename-template.sh" \
|
||||
$(CURDIR)/ $(source_intermediate_directory)
|
||||
cd $(source_intermediate_directory) && \
|
||||
tar czf $(source_package_name).tar.gz ../$(app_name)
|
||||
cd $(CURDIR)/build/artifacts/intermediate-source && \
|
||||
tar czf $(source_package_name).tar.gz $(app_name)
|
||||
|
||||
# appstore:
|
||||
# - Create an App Store tarball (strips tests, dotfiles, dev configs)
|
||||
@@ -189,10 +190,11 @@ appstore:
|
||||
--exclude="protractor\.*" \
|
||||
--exclude=".*" \
|
||||
--exclude="dist/js/.*" \
|
||||
--exclude="src" \
|
||||
--exclude="/src" \
|
||||
--exclude="rename-template.sh" \
|
||||
$(CURDIR)/ $(app_intermediate_directory)
|
||||
cd $(app_intermediate_directory) && \
|
||||
tar czf $(appstore_package_name).tar.gz ../$(app_name)
|
||||
cd $(CURDIR)/build/artifacts/intermediate && \
|
||||
tar czf $(appstore_package_name).tar.gz $(app_name)
|
||||
|
||||
# test:
|
||||
# - Run PHP unit tests (standard + optional integration config)
|
||||
|
||||
12
README.md
12
README.md
@@ -5,11 +5,20 @@ SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
# Nextcloud Forum
|
||||
|
||||

|
||||
|
||||
A full-featured forum application for Nextcloud, allowing users to create discussion categories,
|
||||
threads, and posts within their Nextcloud instance.
|
||||
|
||||

|
||||
|
||||
## ⚠️ Early Development Notice
|
||||
|
||||
**This app is in early stages of development.** While functional, you may encounter bugs or
|
||||
incomplete features. Please report any issues on
|
||||
[GitHub](https://github.com/chenasraf/nextcloud-forum/issues) and consider backing up your data
|
||||
regularly.
|
||||
|
||||
## Features
|
||||
|
||||
- **Category Management**: Organize discussions into categories with headers and custom permissions
|
||||
@@ -30,8 +39,7 @@ Install Forum directly from your Nextcloud instance through the Apps page.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the latest release from the
|
||||
[releases page](https://github.com/yourusername/forum/releases)
|
||||
1. Download the latest release from the [releases page](https://github.com/chenasraf/forum/releases)
|
||||
2. Extract to your Nextcloud apps directory:
|
||||
|
||||
```bash
|
||||
|
||||
11
appinfo/console.php
Normal file
11
appinfo/console.php
Normal 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));
|
||||
@@ -10,6 +10,9 @@
|
||||
<description><![CDATA[
|
||||
Create discussions, share ideas, and collaborate with your community directly in Nextcloud.
|
||||
|
||||
**⚠️ Early Development Notice:**
|
||||
This app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.
|
||||
|
||||
**Key Features:**
|
||||
- **Thread-based Discussions** - Create and reply to organized discussion threads
|
||||
- **Category Organization** - Structure your forum with customizable categories and headers
|
||||
@@ -33,7 +36,7 @@ Create discussions, share ideas, and collaborate with your community directly in
|
||||
|
||||
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
|
||||
]]></description>
|
||||
<version>0.1.2</version>
|
||||
<version>0.4.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
|
||||
<namespace>Forum</namespace>
|
||||
@@ -52,6 +55,13 @@ 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\RebuildUserStatsTask</job>
|
||||
</background-jobs>
|
||||
<commands>
|
||||
<command>OCA\Forum\Command\TestNotifier</command>
|
||||
<command>OCA\Forum\Command\RebuildUserStats</command>
|
||||
</commands>
|
||||
<navigations>
|
||||
<navigation role="all">
|
||||
<name>Forum</name>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nextcloud/forum",
|
||||
"description": "Automatically fills the currency rates for your Cospend projects daily.",
|
||||
"description": "A community-driven forum built right into your Nextcloud instance",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
8
composer/autoload.php
Normal file
8
composer/autoload.php
Normal 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';
|
||||
@@ -1 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path id="app-light" d="M17,12L17,3C17,2.451 16.549,2 16,2L3,2C2.451,2 2,2.451 2,3L2,17L6,13L16,13C16.549,13 17,12.549 17,12M21,6L19,6L19,15L6,15L6,17C6,17.549 6.451,18 7,18L18,18L22,22L22,7C22,6.451 21.549,6 21,6Z" style="fill-rule:nonzero;"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 508 B After Width: | Height: | Size: 699 B |
@@ -1 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#fff"><path d="M0 0h24v24H0z" fill="none"/><path d="M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path id="app" d="M17,12L17,3C17,2.451 16.549,2 16,2L3,2C2.451,2 2,2.451 2,3L2,17L6,13L16,13C16.549,13 17,12.549 17,12M21,6L19,6L19,15L6,15L6,17C6,17.549 6.451,18 7,18L18,18L22,22L22,7C22,6.451 21.549,6 21,6Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 500 B After Width: | Height: | Size: 704 B |
@@ -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;
|
||||
@@ -33,6 +34,9 @@ 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 {
|
||||
|
||||
40
lib/Command/RebuildUserStats.php
Normal file
40
lib/Command/RebuildUserStats.php
Normal 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\UserStatsService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class RebuildUserStats extends Command {
|
||||
public function __construct(
|
||||
private UserStatsService $userStatsService,
|
||||
) {
|
||||
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->userStatsService->createStatsForAllUsers();
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
81
lib/Command/TestNotifier.php
Normal file
81
lib/Command/TestNotifier.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
57
lib/Controller/SettingsController.php
Normal file
57
lib/Controller/SettingsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
132
lib/Controller/ThreadSubscriptionController.php
Normal file
132
lib/Controller/ThreadSubscriptionController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
lib/Controller/UserPreferencesController.php
Normal file
87
lib/Controller/UserPreferencesController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
lib/Cron/RebuildUserStatsTask.php
Normal file
38
lib/Cron/RebuildUserStatsTask.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Cron;
|
||||
|
||||
use OCA\Forum\Service\UserStatsService;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RebuildUserStatsTask extends TimedJob {
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private UserStatsService $userStatsService,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
|
||||
// Run once a week (604800 seconds = 7 days)
|
||||
$this->setInterval(604800);
|
||||
}
|
||||
|
||||
protected function run($arguments): void {
|
||||
$this->logger->info('Starting weekly user stats rebuild for all users');
|
||||
|
||||
$result = $this->userStatsService->createStatsForAllUsers();
|
||||
|
||||
$this->logger->info('User stats rebuild completed', [
|
||||
'users' => $result['users'],
|
||||
'created' => $result['created'],
|
||||
'updated' => $result['updated'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
44
lib/Db/ThreadSubscription.php
Normal file
44
lib/Db/ThreadSubscription.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
142
lib/Db/ThreadSubscriptionMapper.php
Normal file
142
lib/Db/ThreadSubscriptionMapper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -8,27 +8,47 @@ declare(strict_types=1);
|
||||
namespace OCA\Forum\Listener;
|
||||
|
||||
use OCA\Forum\Db\UserStatsMapper;
|
||||
use OCA\Forum\Service\UserStatsService;
|
||||
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 UserStatsService $userStatsService,
|
||||
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->userStatsService->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();
|
||||
|
||||
@@ -19,6 +19,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
* @param array $options
|
||||
*/
|
||||
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,16 +66,16 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('can_access_admin_tools', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('can_edit_roles', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('can_edit_categories', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('created_at', 'integer', [
|
||||
'notnull' => true,
|
||||
@@ -260,20 +261,20 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('can_view', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 1,
|
||||
'notnull' => false,
|
||||
'default' => true,
|
||||
]);
|
||||
$table->addColumn('can_post', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('can_reply', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('can_moderate', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
$table->addIndex(['category_id'], 'forum_cat_perms_category_idx');
|
||||
@@ -306,16 +307,16 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'notnull' => false,
|
||||
]);
|
||||
$table->addColumn('enabled', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 1,
|
||||
'notnull' => false,
|
||||
'default' => true,
|
||||
]);
|
||||
$table->addColumn('parse_inner', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 1,
|
||||
'notnull' => false,
|
||||
'default' => true,
|
||||
]);
|
||||
$table->addColumn('is_builtin', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('special_handler', 'string', [
|
||||
'notnull' => false,
|
||||
@@ -372,16 +373,16 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'unsigned' => true,
|
||||
]);
|
||||
$table->addColumn('is_locked', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('is_pinned', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('is_hidden', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('created_at', 'integer', [
|
||||
'notnull' => true,
|
||||
@@ -431,12 +432,12 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('is_edited', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('is_first_post', 'boolean', [
|
||||
'notnull' => true,
|
||||
'default' => 0,
|
||||
'notnull' => false,
|
||||
'default' => false,
|
||||
]);
|
||||
$table->addColumn('edited_at', 'integer', [
|
||||
'notnull' => false,
|
||||
@@ -535,8 +536,17 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
// Seed initial data after schema is created
|
||||
$this->seedInitialData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed initial data after schema is created
|
||||
*/
|
||||
private function seedInitialData(): void {
|
||||
$db = \OC::$server->get(\OCP\IDBConnection::class);
|
||||
$userManager = \OC::$server->get(\OCP\IUserManager::class);
|
||||
$groupManager = \OC::$server->get(\OCP\IGroupManager::class);
|
||||
$timestamp = time();
|
||||
|
||||
// Check if data has already been seeded by looking for the Admin role
|
||||
@@ -553,16 +563,26 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create default roles
|
||||
// Find first admin user (fallback to 'admin' if no admin group members found)
|
||||
$adminUserId = 'admin';
|
||||
$adminGroup = $groupManager->get('admin');
|
||||
if ($adminGroup) {
|
||||
$adminUsers = $adminGroup->getUsers();
|
||||
if (!empty($adminUsers)) {
|
||||
$firstAdmin = reset($adminUsers);
|
||||
$adminUserId = $firstAdmin->getUID();
|
||||
}
|
||||
}
|
||||
|
||||
// Create default roles
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_roles')
|
||||
->values([
|
||||
'name' => $qb->createNamedParameter('Admin'),
|
||||
'description' => $qb->createNamedParameter('Administrator role with full permissions'),
|
||||
'can_access_admin_tools' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_edit_roles' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_edit_categories' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_access_admin_tools' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_roles' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_categories' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
@@ -573,9 +593,9 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
->values([
|
||||
'name' => $qb->createNamedParameter('Moderator'),
|
||||
'description' => $qb->createNamedParameter('Moderator role with elevated permissions'),
|
||||
'can_access_admin_tools' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_edit_roles' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_edit_categories' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_access_admin_tools' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_roles' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_categories' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
@@ -586,9 +606,9 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
->values([
|
||||
'name' => $qb->createNamedParameter('User'),
|
||||
'description' => $qb->createNamedParameter('Default user role with basic permissions'),
|
||||
'can_access_admin_tools' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_edit_roles' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_edit_categories' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_access_admin_tools' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_roles' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_edit_categories' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
->executeStatement();
|
||||
@@ -631,7 +651,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),
|
||||
@@ -650,10 +670,10 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($adminRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_post' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_reply' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_moderate' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
|
||||
@@ -663,10 +683,10 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($moderatorRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_post' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_reply' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_moderate' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
|
||||
@@ -676,18 +696,15 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'role_id' => $qb->createNamedParameter($userRoleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_post' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_reply' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_moderate' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'can_view' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_reply' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'can_moderate' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
])
|
||||
->executeStatement();
|
||||
}
|
||||
|
||||
// Create default BBCodes
|
||||
// Note: Most BBCode tags (b, i, u, s, code, email, url, img, quote, youtube, font, size, color, etc.)
|
||||
// are provided by the chriskonnertz/bbcode library and don't need to be stored in the database.
|
||||
// We only store custom BBCodes that extend the library's functionality.
|
||||
$bbcodes = [
|
||||
[
|
||||
'tag' => 'icode',
|
||||
@@ -726,9 +743,9 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
'replacement' => $qb->createNamedParameter($bbcode['replacement']),
|
||||
'example' => $qb->createNamedParameter($bbcode['example']),
|
||||
'description' => $qb->createNamedParameter($bbcode['description']),
|
||||
'enabled' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'parse_inner' => $qb->createNamedParameter($bbcode['parse_inner'] ? 1 : 0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'is_builtin' => $qb->createNamedParameter($bbcode['is_builtin'] ? 1 : 0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'enabled' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'parse_inner' => $qb->createNamedParameter($bbcode['parse_inner'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_builtin' => $qb->createNamedParameter($bbcode['is_builtin'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'special_handler' => $qb->createNamedParameter($bbcode['special_handler']),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
@@ -736,9 +753,6 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
}
|
||||
|
||||
// Assign roles to all Nextcloud users
|
||||
$groupManager = \OC::$server->get(\OCP\IGroupManager::class);
|
||||
$adminGroup = $groupManager->get('admin');
|
||||
|
||||
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $userRoleId, $adminRoleId, $adminGroup) {
|
||||
$userId = $user->getUID();
|
||||
$isAdmin = $adminGroup && $adminGroup->inGroup($user);
|
||||
@@ -771,15 +785,15 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
$qb->insert('forum_threads')
|
||||
->values([
|
||||
'category_id' => $qb->createNamedParameter($generalCategoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'author_id' => $qb->createNamedParameter('admin'),
|
||||
'author_id' => $qb->createNamedParameter($adminUserId),
|
||||
'title' => $qb->createNamedParameter('Welcome to Nextcloud Forums'),
|
||||
'slug' => $qb->createNamedParameter('welcome-to-nextcloud-forums'),
|
||||
'view_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'post_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'last_post_id' => $qb->createNamedParameter(null, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'is_locked' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'is_pinned' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'is_hidden' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'is_locked' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_pinned' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_hidden' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
])
|
||||
@@ -811,11 +825,11 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
$qb->insert('forum_posts')
|
||||
->values([
|
||||
'thread_id' => $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'author_id' => $qb->createNamedParameter('admin'),
|
||||
'author_id' => $qb->createNamedParameter($adminUserId),
|
||||
'content' => $qb->createNamedParameter($welcomeContent),
|
||||
'slug' => $qb->createNamedParameter('welcome-to-nextcloud-forums-1'),
|
||||
'is_edited' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'is_first_post' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'is_edited' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'is_first_post' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
|
||||
'edited_at' => $qb->createNamedParameter(null, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
@@ -830,11 +844,11 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
->where($qb->expr()->eq('id', $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
|
||||
->executeStatement();
|
||||
|
||||
// Create user stats for admin (who created the welcome post/thread)
|
||||
// Create user stats for the admin user (who created the welcome post/thread)
|
||||
$qb = $db->getQueryBuilder();
|
||||
$qb->insert('forum_user_stats')
|
||||
->values([
|
||||
'user_id' => $qb->createNamedParameter('admin'),
|
||||
'user_id' => $qb->createNamedParameter($adminUserId),
|
||||
'post_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'thread_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
'last_post_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
|
||||
@@ -843,4 +857,5 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
|
||||
])
|
||||
->executeStatement();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
119
lib/Migration/Version2Date20251114222614.php
Normal file
119
lib/Migration/Version2Date20251114222614.php
Normal 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\UserStatsService;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version2Date20251114222614 extends SimpleMigrationStep {
|
||||
public function __construct(
|
||||
private UserStatsService $userStatsService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @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->userStatsService->createStatsForAllUsers();
|
||||
|
||||
$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!');
|
||||
}
|
||||
|
||||
}
|
||||
112
lib/Notification/Notifier.php
Normal file
112
lib/Notification/Notifier.php
Normal 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);
|
||||
}
|
||||
}
|
||||
155
lib/Service/NotificationService.php
Normal file
155
lib/Service/NotificationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
142
lib/Service/UserPreferencesService.php
Normal file
142
lib/Service/UserPreferencesService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
173
lib/Service/UserStatsService.php
Normal file
173
lib/Service/UserStatsService.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?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 UserStatsService {
|
||||
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 createStatsForAllUsers(): 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 user statistics from actual post and thread counts
|
||||
*
|
||||
* @return array{users: int, updated: int, created: int} Statistics about the rebuild
|
||||
*/
|
||||
public function rebuildAllUserStats(): array {
|
||||
// Delegate to createStatsForAllUsers which processes all Nextcloud users
|
||||
return $this->createStatsForAllUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild statistics for a single user
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
811
openapi.json
811
openapi.json
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,12 +14,25 @@
|
||||
</template>
|
||||
</NcButton>
|
||||
|
||||
<NcEmojiPicker @select="handleEmojiSelect">
|
||||
<NcButton
|
||||
variant="tertiary"
|
||||
:aria-label="strings.emojiLabel"
|
||||
:title="strings.emojiLabel"
|
||||
class="bbcode-button"
|
||||
>
|
||||
<template #icon>
|
||||
<EmoticonIcon :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</NcEmojiPicker>
|
||||
|
||||
<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 NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker'
|
||||
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,
|
||||
NcEmojiPicker,
|
||||
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>
|
||||
|
||||
75
src/components/PageHeader.vue
Normal file
75
src/components/PageHeader.vue
Normal 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>
|
||||
58
src/components/PageWrapper.vue
Normal file
58
src/components/PageWrapper.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<NcEmojiPicker @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>
|
||||
</NcEmojiPicker>
|
||||
</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 NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PostReactions',
|
||||
components: {
|
||||
NcEmojiPicker,
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
99
src/components/Skeleton.vue
Normal file
99
src/components/Skeleton.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
87
src/components/UserAvatar.vue
Normal file
87
src/components/UserAvatar.vue
Normal 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
146
src/components/UserInfo.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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') },
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface Thread {
|
||||
authorIsDeleted?: boolean
|
||||
categorySlug?: string | null
|
||||
categoryName?: string | null
|
||||
isSubscribed?: boolean
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
282
src/views/UserPreferencesView.vue
Normal file
282
src/views/UserPreferencesView.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -559,8 +568,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 +590,6 @@ export default defineComponent({
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-actions {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
@@ -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,264 @@
|
||||
</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' && !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' && !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 +375,9 @@ import type { CategoryHeader, Category, CatHeader } from '@/types'
|
||||
export default defineComponent({
|
||||
name: 'AdminCategoryList',
|
||||
components: {
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
AppToolbar,
|
||||
NcButton,
|
||||
NcCheckboxRadioSwitch,
|
||||
NcDialog,
|
||||
@@ -753,8 +761,6 @@ export default defineComponent({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-category-list {
|
||||
max-width: 1200px;
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-maxcontrast);
|
||||
opacity: 0.7;
|
||||
@@ -774,22 +780,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;
|
||||
}
|
||||
}
|
||||
|
||||
.category-list {
|
||||
.categories-section {
|
||||
display: flex;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,104 +1,106 @@
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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 class="col-name">
|
||||
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
|
||||
</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-description">
|
||||
<span v-if="role.description" class="role-description">{{ role.description }}</span>
|
||||
<span v-else class="muted">{{ strings.noDescription }}</span>
|
||||
</div>
|
||||
<div class="col-name">
|
||||
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="col-created">
|
||||
<NcDateTime :timestamp="role.createdAt * 1000" />
|
||||
</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-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 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>
|
||||
</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>
|
||||
<!-- 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">
|
||||
@@ -112,6 +114,9 @@ import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
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'
|
||||
@@ -128,6 +133,9 @@ export default defineComponent({
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
DeleteIcon,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
AppToolbar,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,145 +1,148 @@
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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 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" />
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.userId"
|
||||
class="table-row"
|
||||
:class="{ 'is-deleted': user.isDeleted }"
|
||||
>
|
||||
<div class="col-user">
|
||||
<UserInfo :user-id="user.userId" :display-name="user.displayName" :avatar-size="40">
|
||||
<template #meta>
|
||||
<div class="user-id muted">@{{ user.userId }}</div>
|
||||
</template>
|
||||
</NcButton>
|
||||
</UserInfo>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-joined">
|
||||
<NcDateTime :timestamp="user.createdAt * 1000" />
|
||||
</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-status">
|
||||
<span v-if="user.isDeleted" class="status-badge status-deleted">
|
||||
{{ strings.deleted }}
|
||||
</span>
|
||||
<span v-else class="status-badge status-active">
|
||||
{{ strings.active }}
|
||||
</span>
|
||||
<div class="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent
|
||||
v-else
|
||||
:title="strings.emptyTitle"
|
||||
:description="strings.emptyDesc"
|
||||
class="mt-16"
|
||||
/>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -147,12 +150,14 @@ 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 NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import UserInfo from '@/components/UserInfo.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'
|
||||
@@ -180,9 +185,11 @@ export default defineComponent({
|
||||
NcButton,
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
NcAvatar,
|
||||
NcDateTime,
|
||||
NcSelect,
|
||||
UserInfo,
|
||||
PageWrapper,
|
||||
PageHeader,
|
||||
PencilIcon,
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
@@ -395,23 +402,8 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.col-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.user-id {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.2
|
||||
0.4.0
|
||||
|
||||
Reference in New Issue
Block a user