Compare commits

...

88 Commits

Author SHA1 Message Date
b2bf402e20 chore(master): release 0.8.1 2025-11-21 09:41:46 +02:00
c6d757bee6 fix: migration 2025-11-21 09:40:04 +02:00
35c0fa7e1d chore(master): release 0.8.0 2025-11-21 02:30:26 +02:00
b93c45adc7 feat(CategoryView): use fixed-width page 2025-11-21 02:23:29 +02:00
a3f77e23c5 chore(deps): update dependencies 2025-11-21 02:18:37 +02:00
461fd68c14 build: type check during build/dev 2025-11-21 02:14:14 +02:00
bc3892e9a2 feat(AdminDashboard): add recent contributors view 2025-11-21 02:09:08 +02:00
ba552a2c01 feat: rebuild-all-stats and rebuild-thread-stats commands 2025-11-21 01:57:11 +02:00
b38f7d31fa chore: update .l10nignore 2025-11-21 01:50:42 +02:00
7de5cc56ef fix(AdminUserList): edit role dialog select width 2025-11-21 01:50:14 +02:00
85f2b4c667 feat: unify action buttons ui/design 2025-11-21 01:23:09 +02:00
3af9c9ce68 fix: search results ui 2025-11-21 01:07:06 +02:00
88cb7f5aa9 feat: add role colors + improve user data structure/enrichment 2025-11-21 01:00:45 +02:00
90459368b1 fix: use l10n for db seeds 2025-11-20 21:11:41 +02:00
1ca534c90f docs: update screenshot url 2025-11-20 15:13:47 +02:00
8ba4efb92c chore(master): release 0.7.0 2025-11-20 14:24:48 +02:00
d6d4694ce0 fix: build excluded files 2025-11-20 14:22:04 +02:00
84edf8ecbe feat: weekly task now calculates category/thread post counts 2025-11-20 11:18:41 +02:00
96a42525d3 feat: add forum:set-role occ command 2025-11-20 11:07:03 +02:00
84fe339fc0 chore(master): release 0.6.0 2025-11-20 10:25:54 +02:00
ce6b334dd3 build: clean up asset loaders 2025-11-20 10:14:15 +02:00
92418cc543 build: improve asset chunk logic 2025-11-20 04:37:46 +02:00
2753ecfefb build: improve asset/script loading 2025-11-20 04:37:17 +02:00
53130ca10a chore: update scaffolds 2025-11-20 04:35:38 +02:00
e97302b861 build: fix build errors 2025-11-20 01:58:19 +02:00
c8ca4f9168 feat(AdminCategoryEdit): pre-populate role dropdowns with default roles 2025-11-20 01:52:05 +02:00
c16e804d16 fix(AdminUserList): empty state display condition 2025-11-20 01:46:57 +02:00
22f9b78b1b fix(AdminCategoryList): list spacing 2025-11-20 01:46:40 +02:00
432c31f6e2 feat(AdminTable): improve users/role tables design 2025-11-20 01:41:46 +02:00
46367aa0d8 docs: update screenshot 2025-11-19 23:40:57 +02:00
f4fe09fae3 chore(master): release 0.5.0 2025-11-19 20:36:07 +02:00
5c66f44da5 fix(AdminCategoryList): allow deleting empty categories/headers 2025-11-19 20:27:11 +02:00
363e04b4e9 feat(AdminCategoryEdit): auto-populate slug on create 2025-11-19 20:08:46 +02:00
1fd59c1f04 fix: improve admin role assignment to users 2025-11-19 19:43:25 +02:00
5391d8fffe chore(master): release 0.4.0 2025-11-19 09:32:37 +02:00
b0bfbbccdf feat(BBCodeEditor): add attachment disclaimer 2025-11-19 02:54:07 +02:00
9525ebfb97 fix(ThreadCard): mobile responsiveness 2025-11-19 02:54:07 +02:00
67e9fb9f8c fix(ProfileView): mobile responsiveness 2025-11-19 02:54:06 +02:00
a36da9f882 feat(AppNavigation): save collapse state to local storage 2025-11-19 02:54:06 +02:00
c0762158d7 fix: mobile responsiveness 2025-11-19 02:54:06 +02:00
479cdbbba5 refactor: clean up AppNavigation active logic 2025-11-19 02:54:05 +02:00
255a5cf53d feat(BBCodeToolbar): add emoji picker button 2025-11-19 02:54:05 +02:00
feeefa2926 feat(PostReactions): use Nextcloud emoji picker 2025-11-19 02:54:03 +02:00
f49561ccca chore(master): release 0.3.0 2025-11-18 10:31:32 +02:00
e59a6f4dc7 feat: add skeleton component + update categories header ui 2025-11-18 10:26:51 +02:00
9719f518e2 feat: load forum title/subtitle from public endpoint 2025-11-18 10:26:50 +02:00
2d10b461c0 feat: add page header component 2025-11-18 10:26:50 +02:00
2264289b56 refactor: move AppToolbar position to PageWrapper slot 2025-11-18 02:44:59 +02:00
3ef545dcc9 refactor: add PageWrapper component 2025-11-18 02:21:08 +02:00
fb905f8d15 docs: add release to README.md 2025-11-18 02:13:19 +02:00
278f1b3cc4 feat: user preferences page & auto thread subs pref 2025-11-18 01:38:57 +02:00
5ee8a16aa1 fix: user stats post is_first_post counts 2025-11-17 18:22:41 +02:00
a1671baf2d chore(master): release 0.2.1 2025-11-17 18:15:40 +02:00
71ee133ac6 fix: unread counts for deleted posts 2025-11-17 18:13:19 +02:00
1add8db287 fix: thread card hover styles 2025-11-17 17:52:34 +02:00
e1e3ede1d8 chore(master): release 0.2.0 2025-11-17 10:09:24 +02:00
9833e51997 fix: admin/mod post permissions 2025-11-17 10:01:42 +02:00
664ee53670 fix: user avatar container size 2025-11-17 09:45:40 +02:00
7a80c19613 chore: fix ts errors 2025-11-17 03:18:49 +02:00
8cc34d9d7a feat: update thread card user info display 2025-11-17 03:16:01 +02:00
364226fdc8 fix: create user stats for existing users 2025-11-17 03:10:09 +02:00
11aa3af887 feat: unify user info component 2025-11-17 03:05:55 +02:00
0de120f2bf feat: rebuild user stats task & command 2025-11-17 02:42:06 +02:00
e590f73fc0 fix: user stats table 2025-11-17 02:41:54 +02:00
4ca6388923 feat: add emoji picker close icon 2025-11-17 01:48:25 +02:00
cdecdce9d1 fix: emoji picker position 2025-11-17 01:44:53 +02:00
bf59b47b2a build: exclude openapi from precommit formatting 2025-11-17 01:32:49 +02:00
2fbe180d5e feat: thread subscriptions & notifications 2025-11-17 01:27:17 +02:00
d16288f237 fix: default support category sort order 2025-11-16 23:15:24 +02:00
6ba8034b75 fix: autoload 2025-11-16 16:35:45 +02:00
860092d6a9 chore(master): release 0.1.7 2025-11-16 16:27:45 +02:00
29311708a5 fix: autoload 2025-11-16 16:18:55 +02:00
51bcf64213 fix: update tar build tar 2025-11-16 14:53:56 +02:00
4e6ba7cb28 docs: add disclaimer 2025-11-16 14:28:01 +02:00
dfed1dd340 chore(master): release 0.1.6 2025-11-16 14:20:11 +02:00
34853a9844 fix: bbcode parsing source 2025-11-16 14:17:22 +02:00
ae5ed0cbd4 chore(master): release 0.1.5 2025-11-16 02:34:01 +02:00
f2adfa8389 fix: schema seed stage 2025-11-16 02:26:21 +02:00
b8da663739 chore(master): release 0.1.4 2025-11-16 01:16:06 +02:00
0b2efa576e fix: migration seed step + admin user fetching 2025-11-16 01:12:02 +02:00
c884e82505 build: exclude rename-template.sh 2025-11-16 01:12:02 +02:00
d8883aa40a fix: add app icon 2025-11-16 01:12:02 +02:00
6b049bdda3 fix: migration default values 2025-11-16 00:44:36 +02:00
d84dd62cf0 docs: update README.md 2025-11-16 00:12:42 +02:00
7574cb59c0 chore(master): release 0.1.3 2025-11-16 00:12:31 +02:00
e778163889 fix: migration default values 2025-11-16 00:08:42 +02:00
c8dfc2d542 chore(master): release 0.1.2 2025-11-15 23:54:12 +02:00
fa2765f3f0 fix: migration default values 2025-11-15 23:52:24 +02:00
102 changed files with 8444 additions and 4061 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,2 +1,3 @@
dist/
vendor/
gen/

View File

@@ -1,6 +1,10 @@
module.exports = {
'*.{ts,vue}': ['eslint --fix'],
'*.{scss,vue,ts,md,json}': ['prettier --write'],
'*.{scss,vue,ts,md}': ['prettier --write'],
'*.json': (files) => {
const filtered = files.filter(file => !file.includes('openapi.json'));
return filtered.length > 0 ? `prettier --write ${filtered.join(' ')}` : [];
},
'*.php': [() => 'make php-cs-fixer'],
'*Controller.php': [() => 'make openapi', () => 'git add openapi.json'],
}

View File

@@ -1 +1 @@
{".":"0.1.1"}
{".":"0.8.1"}

View File

@@ -1,5 +1,177 @@
# Changelog
## [0.8.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.8.0...v0.8.1) (2025-11-21)
### Bug Fixes
* migration ([c6d757b](https://github.com/chenasraf/nextcloud-forum/commit/c6d757bee606280061a3ba359d3137b95838f444))
## [0.8.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.7.0...v0.8.0) (2025-11-21)
### Features
* add role colors + improve user data structure/enrichment ([88cb7f5](https://github.com/chenasraf/nextcloud-forum/commit/88cb7f5aa946fae974344622e194f0ef23392913))
* **AdminDashboard:** add recent contributors view ([bc3892e](https://github.com/chenasraf/nextcloud-forum/commit/bc3892e9a2a2c47520973dfeaf810d2d32366a1c))
* **CategoryView:** use fixed-width page ([b93c45a](https://github.com/chenasraf/nextcloud-forum/commit/b93c45adc78377c7e9971446c8c03b749bc523fc))
* rebuild-all-stats and rebuild-thread-stats commands ([ba552a2](https://github.com/chenasraf/nextcloud-forum/commit/ba552a2c01783de7161b5b6d16fdd7c4081a8b28))
* unify action buttons ui/design ([85f2b4c](https://github.com/chenasraf/nextcloud-forum/commit/85f2b4c66794c7f6b622406954fd57cf88bba371))
### Bug Fixes
* **AdminUserList:** edit role dialog select width ([7de5cc5](https://github.com/chenasraf/nextcloud-forum/commit/7de5cc56efee39a892ca200da7f52a7727b8cd00))
* search results ui ([3af9c9c](https://github.com/chenasraf/nextcloud-forum/commit/3af9c9ce68bc7966b94d89fb6a32f163ad4aaadc))
* use l10n for db seeds ([9045936](https://github.com/chenasraf/nextcloud-forum/commit/90459368b1b6a4536ba75d139422019f0e836676))
## [0.7.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.6.0...v0.7.0) (2025-11-20)
### Features
* add forum:set-role occ command ([96a4252](https://github.com/chenasraf/nextcloud-forum/commit/96a42525d342ca0e791ea20b224838fc395f906c))
* weekly task now calculates category/thread post counts ([84edf8e](https://github.com/chenasraf/nextcloud-forum/commit/84edf8ecbe3512d948960948299d378fae4b2c91))
### Bug Fixes
* build excluded files ([d6d4694](https://github.com/chenasraf/nextcloud-forum/commit/d6d4694ce0cc64c0c220bb834bcec15ec107e343))
## [0.6.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.5.0...v0.6.0) (2025-11-20)
### Features
* **AdminCategoryEdit:** pre-populate role dropdowns with default roles ([c8ca4f9](https://github.com/chenasraf/nextcloud-forum/commit/c8ca4f9168d597d1f6281a9fab794052b6f9a33b))
* **AdminTable:** improve users/role tables design ([432c31f](https://github.com/chenasraf/nextcloud-forum/commit/432c31f6e2c71c1b18216f59a31e69d34223baff))
### Bug Fixes
* **AdminCategoryList:** list spacing ([22f9b78](https://github.com/chenasraf/nextcloud-forum/commit/22f9b78b1be0b9787a5d8ea0bf648630d086c7f6))
* **AdminUserList:** empty state display condition ([c16e804](https://github.com/chenasraf/nextcloud-forum/commit/c16e804d16480936f9f38903989e90aeecc4cd5b))
## [0.5.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.4.0...v0.5.0) (2025-11-19)
### Features
* **AdminCategoryEdit:** auto-populate slug on create ([363e04b](https://github.com/chenasraf/nextcloud-forum/commit/363e04b4e93f896648fa2d58d4d5718cc924236c))
### Bug Fixes
* **AdminCategoryList:** allow deleting empty categories/headers ([5c66f44](https://github.com/chenasraf/nextcloud-forum/commit/5c66f44da520fce0d35f384e13452f8cae1427ab))
* improve admin role assignment to users ([1fd59c1](https://github.com/chenasraf/nextcloud-forum/commit/1fd59c1f0462e02aa49703191365a9d564f5c8e9))
## [0.4.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.3.0...v0.4.0) (2025-11-19)
### Features
* **AppNavigation:** save collapse state to local storage ([a36da9f](https://github.com/chenasraf/nextcloud-forum/commit/a36da9f8822aa6b091e34d82cce8b56a86547b39))
* **BBCodeEditor:** add attachment disclaimer ([b0bfbbc](https://github.com/chenasraf/nextcloud-forum/commit/b0bfbbccdf04bd92d374ed31e404c9fadc23f51b))
* **BBCodeToolbar:** add emoji picker button ([255a5cf](https://github.com/chenasraf/nextcloud-forum/commit/255a5cf53dcce38c9356b30713a76e95592abe44))
* **PostReactions:** use Nextcloud emoji picker ([feeefa2](https://github.com/chenasraf/nextcloud-forum/commit/feeefa2926589cbd0c62053f1700c9bfb6bca545))
### Bug Fixes
* mobile responsiveness ([c076215](https://github.com/chenasraf/nextcloud-forum/commit/c0762158d75e6eebf0ac77a512218cf7b4119a97))
* **ProfileView:** mobile responsiveness ([67e9fb9](https://github.com/chenasraf/nextcloud-forum/commit/67e9fb9f8cdb9d1ada660b1d90e8de5aa35051de))
* **ThreadCard:** mobile responsiveness ([9525ebf](https://github.com/chenasraf/nextcloud-forum/commit/9525ebfb9705e66281898af7fcb733ba1ae8208c))
## [0.3.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.1...v0.3.0) (2025-11-18)
### Features
* add page header component ([2d10b46](https://github.com/chenasraf/nextcloud-forum/commit/2d10b461c018160d63ed6e63479e1488ba8da38e))
* add skeleton component + update categories header ui ([e59a6f4](https://github.com/chenasraf/nextcloud-forum/commit/e59a6f4dc7b60ad0b370b801d541f0007d1896c3))
* load forum title/subtitle from public endpoint ([9719f51](https://github.com/chenasraf/nextcloud-forum/commit/9719f518e2b1a9dced781431a6b0d4123aef952c))
* user preferences page & auto thread subs pref ([278f1b3](https://github.com/chenasraf/nextcloud-forum/commit/278f1b3cc48b6d2e74c383dec34015e3e3cd1e81))
### Bug Fixes
* user stats post is_first_post counts ([5ee8a16](https://github.com/chenasraf/nextcloud-forum/commit/5ee8a16aa13510c7b6a6b48238bc156c27045e7b))
## [0.2.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.0...v0.2.1) (2025-11-17)
### Bug Fixes
* thread card hover styles ([1add8db](https://github.com/chenasraf/nextcloud-forum/commit/1add8db28775d2d13d8b2eb9428a90eb99b32ae8))
* unread counts for deleted posts ([71ee133](https://github.com/chenasraf/nextcloud-forum/commit/71ee133ac6b59f9005918594f7e668031b8224fa))
## [0.2.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.7...v0.2.0) (2025-11-17)
### Features
* add emoji picker close icon ([4ca6388](https://github.com/chenasraf/nextcloud-forum/commit/4ca6388923299751a251f56785c2b29dc2dd75dd))
* rebuild user stats task & command ([0de120f](https://github.com/chenasraf/nextcloud-forum/commit/0de120f2bf88bd377aa13a760f29f1b46ece98e9))
* thread subscriptions & notifications ([2fbe180](https://github.com/chenasraf/nextcloud-forum/commit/2fbe180d5e8a6e6fdd02b3506896f9355d6bef22))
* unify user info component ([11aa3af](https://github.com/chenasraf/nextcloud-forum/commit/11aa3af887f17c3236ff8abcc8ef1d3b15ee03c2))
* update thread card user info display ([8cc34d9](https://github.com/chenasraf/nextcloud-forum/commit/8cc34d9d7a0711d43b938b9fd686a8ea682160cf))
### Bug Fixes
* admin/mod post permissions ([9833e51](https://github.com/chenasraf/nextcloud-forum/commit/9833e519973da5ff059ef0346333bdc96d73c072))
* autoload ([6ba8034](https://github.com/chenasraf/nextcloud-forum/commit/6ba8034b7535d1c449e8b75d5645398f948b7941))
* create user stats for existing users ([364226f](https://github.com/chenasraf/nextcloud-forum/commit/364226fdc84713162b1b59d3ec17455177a7ba81))
* default support category sort order ([d16288f](https://github.com/chenasraf/nextcloud-forum/commit/d16288f237e07ad7d3a5726029de39c7bee7b8da))
* emoji picker position ([cdecdce](https://github.com/chenasraf/nextcloud-forum/commit/cdecdce9d18828e227be0994b9ccf065eba9c831))
* user avatar container size ([664ee53](https://github.com/chenasraf/nextcloud-forum/commit/664ee536705bd2d8fab64470a2a2600ab30e3d26))
* user stats table ([e590f73](https://github.com/chenasraf/nextcloud-forum/commit/e590f73fc02f32c6d0f908e895441f4405240ec7))
## [0.1.7](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.6...v0.1.7) (2025-11-16)
### 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)
### Bug Fixes
* migration default values ([fa2765f](https://github.com/chenasraf/nextcloud-forum/commit/fa2765f3f03f19bd39c1b83517c357451b71cf6c))
## [0.1.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.0...v0.1.1) (2025-11-15)

View File

@@ -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)
@@ -187,12 +188,14 @@ appstore:
--exclude="bower.json" \
--exclude="karma.*" \
--exclude="protractor\.*" \
--exclude=".*" \
--exclude="/gen" \
--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)

View File

@@ -5,11 +5,20 @@ SPDX-License-Identifier: CC0-1.0
# Nextcloud Forum
![GitHub Release](https://img.shields.io/github/v/release/chenasraf/nextcloud-forum)
A full-featured forum application for Nextcloud, allowing users to create discussion categories,
threads, and posts within their Nextcloud instance.
![Screenshot](/screenshots/screenshot-01.png)
## ⚠️ 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
View File

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

View File

@@ -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.1</version>
<version>0.8.1</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>
@@ -46,12 +49,22 @@ The forum integrates seamlessly with your Nextcloud instance, using your existin
<website>https://github.com/chenasraf/nextcloud-forum</website>
<bugs>https://github.com/chenasraf/nextcloud-forum/issues</bugs>
<repository>https://github.com/chenasraf/nextcloud-forum</repository>
<screenshot>https://raw.githubusercontent.com/chenasraf/nextcloud-forum/refs/heads/master/screenshots/screenshot-01.png</screenshot>
<screenshot>https://raw.githubusercontent.com/chenasraf/nextcloud-forum/master/screenshots/screenshot-01.png</screenshot>
<donation>https://ko-fi.com/casraf</donation>
<donation type="paypal"><![CDATA[https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=TSH3C3ABGQM22&currency_code=ILS&source=url]]></donation>
<dependencies>
<nextcloud min-version="29" max-version="33"/>
</dependencies>
<background-jobs>
<job>OCA\Forum\Cron\RebuildStatsTask</job>
</background-jobs>
<commands>
<command>OCA\Forum\Command\TestNotifier</command>
<command>OCA\Forum\Command\RebuildUserStats</command>
<command>OCA\Forum\Command\RebuildThreadStats</command>
<command>OCA\Forum\Command\RebuildAllStats</command>
<command>OCA\Forum\Command\SetRole</command>
</commands>
<navigations>
<navigation role="all">
<name>Forum</name>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ namespace OCA\Forum\AppInfo;
use OCA\Forum\Listener\UserEventListener;
use OCA\Forum\Middleware\PermissionMiddleware;
use OCA\Forum\Notification\Notifier;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -33,11 +34,41 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(UserCreatedEvent::class, UserEventListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserEventListener::class);
$context->registerEventListener(UserChangedEvent::class, UserEventListener::class);
// Register notification notifier
$context->registerNotifierService(Notifier::class);
}
public function boot(IBootContext $context): void {
}
/**
* Helper to parse Vite Manifest
*/
public static function getViteEntryScript(string $entryName): string {
$jsDir = realpath(__DIR__ . '/../' . Application::JS_DIR);
$manifestPath = dirname($jsDir) . '/.vite/manifest.json';
if (!file_exists($manifestPath)) {
return '';
}
$manifest = json_decode(file_get_contents($manifestPath), true);
if (isset($manifest[$entryName]['file'])) {
$manifestFile = $manifest[$entryName]['file'];
$fullPath = dirname($jsDir) . '/' . $manifestFile;
if (!file_exists($fullPath)) {
return '';
}
return pathinfo($manifestFile, PATHINFO_FILENAME);
}
return '';
}
public static function tableName(string $name): string {
// return self::APP_ID . '_' . $name;
return $name;

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Command;
use OCA\Forum\Service\StatsService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RebuildAllStats extends Command {
/**
* RebuildAllStats constructor.
*/
public function __construct(
private StatsService $statsService,
) {
parent::__construct();
}
/**
*
*/
protected function configure(): void {
parent::configure();
$this->setName('forum:rebuild-all-stats');
$this->setDescription('Rebuild all statistics including users, categories, threads, and posts');
}
/**
* @param InputInterface $input
* @param OutputInterface $output
*
* @throws Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('<info>Starting full stats rebuild...</info>');
// Rebuild user stats
$output->writeln('Rebuilding user stats...');
$userResult = $this->statsService->rebuildAllUserStats();
$output->writeln(sprintf(
' <comment>Users processed: %d, created: %d, updated: %d</comment>',
$userResult['users'],
$userResult['created'],
$userResult['updated']
));
// Rebuild category stats
$output->writeln('Rebuilding category stats...');
$categoryResult = $this->statsService->rebuildAllCategoryStats();
$output->writeln(sprintf(
' <comment>Categories processed: %d, updated: %d</comment>',
$categoryResult['categories'],
$categoryResult['updated']
));
// Rebuild thread stats
$output->writeln('Rebuilding thread stats...');
$threadResult = $this->statsService->rebuildAllThreadStats();
$output->writeln(sprintf(
' <comment>Threads processed: %d, updated: %d</comment>',
$threadResult['threads'],
$threadResult['updated']
));
$output->writeln('<info>Full stats rebuild completed!</info>');
return 0;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Command;
use OCA\Forum\Service\StatsService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RebuildThreadStats extends Command {
/**
* RebuildThreadStats constructor.
*/
public function __construct(
private StatsService $statsService,
) {
parent::__construct();
}
/**
*
*/
protected function configure(): void {
parent::configure();
$this->setName('forum:rebuild-thread-stats');
$this->setDescription('Rebuild statistics for categories, threads, and posts');
}
/**
* @param InputInterface $input
* @param OutputInterface $output
*
* @throws Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('<info>Starting thread stats rebuild...</info>');
// Rebuild category stats
$output->writeln('Rebuilding category stats...');
$categoryResult = $this->statsService->rebuildAllCategoryStats();
$output->writeln(sprintf(
' <comment>Categories processed: %d, updated: %d</comment>',
$categoryResult['categories'],
$categoryResult['updated']
));
// Rebuild thread stats
$output->writeln('Rebuilding thread stats...');
$threadResult = $this->statsService->rebuildAllThreadStats();
$output->writeln(sprintf(
' <comment>Threads processed: %d, updated: %d</comment>',
$threadResult['threads'],
$threadResult['updated']
));
$output->writeln('<info>Thread stats rebuild completed!</info>');
return 0;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Command;
use OCA\Forum\Service\StatsService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RebuildUserStats extends Command {
public function __construct(
private StatsService $statsService,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('forum:rebuild-user-stats')
->setDescription('Rebuild user statistics for all users in the system');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('<info>Rebuilding user statistics for all users...</info>');
$result = $this->statsService->rebuildAllUserStats();
$output->writeln(sprintf('Processed %d users', $result['users']));
$output->writeln(sprintf('Created %d new user stats', $result['created']));
$output->writeln(sprintf('Updated %d existing user stats', $result['updated']));
$output->writeln('<info>User statistics rebuilt successfully!</info>');
return 0;
}
}

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

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

View File

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

View File

@@ -7,7 +7,6 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\PostMapper;
@@ -21,6 +20,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
@@ -41,6 +41,7 @@ class AdminController extends OCSController {
private IUserSession $userSession,
private IConfig $config,
private LoggerInterface $logger,
private IL10N $l10n,
) {
parent::__construct($appName, $request);
}
@@ -75,7 +76,8 @@ class AdminController extends OCSController {
$recentPosts = $this->postMapper->countSince($weekAgo);
// Get top contributors (users with most posts)
$topContributors = $this->userStatsMapper->getTopContributors(5);
$topContributorsAllTime = $this->userStatsMapper->getTopContributors(5);
$topContributorsRecent = $this->userStatsMapper->getTopContributorsSince($weekAgo, 5);
return new DataResponse([
'totals' => [
@@ -89,7 +91,8 @@ class AdminController extends OCSController {
'threads' => $recentThreads,
'posts' => $recentPosts,
],
'topContributors' => $topContributors,
'topContributorsAllTime' => $topContributorsAllTime,
'topContributorsRecent' => $topContributorsRecent,
]);
} catch (\Exception $e) {
$this->logger->error('Error fetching dashboard stats: ' . $e->getMessage());
@@ -116,15 +119,19 @@ class AdminController extends OCSController {
$statsByUserId[$stats->getUserId()] = $stats;
}
// Get all Nextcloud users and enrich with forum data
// Collect all user IDs first
$userIds = [];
$this->userManager->callForAllUsers(function ($user) use (&$userIds) {
$userIds[] = $user->getUID();
});
// Enrich all users at once for performance (includes roles)
$enrichedUserData = $this->userService->enrichMultipleUsers($userIds);
// Build final user list with forum stats
$enrichedUsers = [];
$this->userManager->callForAllUsers(function ($user) use (&$enrichedUsers, $statsByUserId) {
$userId = $user->getUID();
// Get user display name
$userInfo = $this->userService->enrichUserData($userId);
// Get stats if they exist, otherwise use defaults
foreach ($userIds as $userId) {
$userInfo = $enrichedUserData[$userId];
$stats = $statsByUserId[$userId] ?? null;
$userData = [
@@ -136,20 +143,11 @@ class AdminController extends OCSController {
'updatedAt' => $stats ? $stats->getUpdatedAt() : 0,
'deletedAt' => $stats ? $stats->getDeletedAt() : null,
'isDeleted' => $userInfo['isDeleted'],
'roles' => [],
'roles' => $userInfo['roles'],
];
// Get user roles
try {
$userRoles = $this->userRoleMapper->findByUserId($userId);
$userData['roles'] = array_map(fn ($ur) => $ur->getRoleId(), $userRoles);
} catch (\Exception $e) {
// User has no roles
$userData['roles'] = [];
}
$enrichedUsers[] = $userData;
});
}
return new DataResponse(['users' => $enrichedUsers]);
} catch (\Exception $e) {
@@ -171,8 +169,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 +195,17 @@ class AdminController extends OCSController {
public function updateSettings(?string $title = null, ?string $subtitle = null): DataResponse {
try {
if ($title !== null) {
$this->config->setAppValue(Application::APP_ID, 'title', $title);
$this->config->setSystemValue('title', $title);
}
if ($subtitle !== null) {
$this->config->setAppValue(Application::APP_ID, 'subtitle', $subtitle);
$this->config->setSystemValue('subtitle', $subtitle);
}
// Return updated settings
$settings = [
'title' => $this->config->getAppValue(Application::APP_ID, 'title', 'Forum'),
'subtitle' => $this->config->getAppValue(Application::APP_ID, 'subtitle', 'Welcome to the forum'),
'title' => $this->config->getSystemValueString('title', $this->l10n->t('Forum')),
'subtitle' => $this->config->getSystemValueString('subtitle', $this->l10n->t('Welcome to the forum!')),
];
return new DataResponse($settings);

View File

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

View File

@@ -17,7 +17,9 @@ 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 OCA\Forum\Service\UserService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -41,6 +43,8 @@ class PostController extends OCSController {
private BBCodeMapper $bbCodeMapper,
private PermissionService $permissionService,
private ReadMarkerMapper $readMarkerMapper,
private NotificationService $notificationService,
private UserService $userService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -84,10 +88,16 @@ class PostController extends OCSController {
// Get current user ID to mark user's reactions
$currentUserId = $this->userSession->getUser()?->getUID();
// Enrich posts with content and reactions
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId) {
// Extract unique author IDs
$authorIds = array_unique(array_map(fn ($p) => $p->getAuthorId(), $posts));
// Batch fetch author data (includes roles)
$authors = $this->userService->enrichMultipleUsers($authorIds);
// Enrich posts with content, reactions, and pre-fetched author data
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $authors) {
$postReactions = $reactionsByPostId[$p->getId()] ?? [];
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId);
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId, $authors[$p->getAuthorId()]);
}, $posts));
} catch (\Exception $e) {
$this->logger->error('Error fetching posts by thread: ' . $e->getMessage());
@@ -132,10 +142,13 @@ class PostController extends OCSController {
// Get current user ID to mark user's reactions
$currentUserId = $this->userSession->getUser()?->getUID();
// Enrich posts with content and reactions
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId) {
// For posts by a single author, we can optimize by fetching author data once
$author = $this->userService->enrichUserData($authorId);
// Enrich posts with content, reactions, and pre-fetched author data
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId, $author) {
$postReactions = $reactionsByPostId[$p->getId()] ?? [];
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId);
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId, $author);
}, $posts));
} catch (\Exception $e) {
$this->logger->error('Error fetching posts by author: ' . $e->getMessage());
@@ -268,6 +281,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 +316,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 +363,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 +378,53 @@ class PostController extends OCSController {
$post->setUpdatedAt(time());
$this->postMapper->update($post);
// Update thread post count
// Update thread post count and lastPostId
try {
$thread = $this->threadMapper->find($post->getThreadId());
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
$thread->setUpdatedAt(time());
// If the deleted post was the last post, update lastPostId to the previous non-deleted post
if ($thread->getLastPostId() === $post->getId()) {
// Find the latest non-deleted post in this thread (excluding the one being deleted)
$latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId());
if ($latestPost) {
$thread->setLastPostId($latestPost->getId());
} else {
// No other posts in thread, set to null (or keep first post ID)
$thread->setLastPostId(null);
}
}
$this->threadMapper->update($thread);
} catch (\Exception $e) {
$this->logger->warning('Failed to update thread post count after post deletion: ' . $e->getMessage());
$this->logger->warning('Failed to update thread after post deletion: ' . $e->getMessage());
// Don't fail the request if thread update fails
}
// Update user stats - decrement post count, and thread count if it's the first post
try {
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
// If this is the first post of a thread, also decrement thread count
if ($post->getIsFirstPost()) {
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
// Update category post count
try {
$category = $this->categoryMapper->find($categoryId);
$category->setPostCount(max(0, $category->getPostCount() - 1));
$this->categoryMapper->update($category);
} catch (\Exception $e) {
$this->logger->warning('Failed to update category post count after post deletion: ' . $e->getMessage());
// Don't fail the request if category update fails
}
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);

View File

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

View File

@@ -79,6 +79,8 @@ class RoleController extends OCSController {
*
* @param string $name Role name
* @param string|null $description Role description
* @param string|null $colorLight Light mode color
* @param string|null $colorDark Dark mode color
* @param bool $canAccessAdminTools Can access admin tools
* @param bool $canEditRoles Can edit roles
* @param bool $canEditCategories Can edit categories
@@ -92,6 +94,8 @@ class RoleController extends OCSController {
public function create(
string $name,
?string $description = null,
?string $colorLight = null,
?string $colorDark = null,
bool $canAccessAdminTools = false,
bool $canEditRoles = false,
bool $canEditCategories = false,
@@ -100,6 +104,8 @@ class RoleController extends OCSController {
$role = new \OCA\Forum\Db\Role();
$role->setName($name);
$role->setDescription($description);
$role->setColorLight($colorLight);
$role->setColorDark($colorDark);
$role->setCanAccessAdminTools($canAccessAdminTools);
$role->setCanEditRoles($canEditRoles);
$role->setCanEditCategories($canEditCategories);
@@ -120,6 +126,8 @@ class RoleController extends OCSController {
* @param int $id Role ID
* @param string|null $name Role name
* @param string|null $description Role description
* @param string|null $colorLight Light mode color
* @param string|null $colorDark Dark mode color
* @param bool|null $canAccessAdminTools Can access admin tools
* @param bool|null $canEditRoles Can edit roles
* @param bool|null $canEditCategories Can edit categories
@@ -134,6 +142,8 @@ class RoleController extends OCSController {
int $id,
?string $name = null,
?string $description = null,
?string $colorLight = null,
?string $colorDark = null,
?bool $canAccessAdminTools = null,
?bool $canEditRoles = null,
?bool $canEditCategories = null,
@@ -147,6 +157,12 @@ class RoleController extends OCSController {
if ($description !== null) {
$role->setDescription($description);
}
if ($colorLight !== null) {
$role->setColorLight($colorLight);
}
if ($colorDark !== null) {
$role->setColorDark($colorDark);
}
if ($canAccessAdminTools !== null) {
$role->setCanAccessAdminTools($canAccessAdminTools);
}

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Db\Post;
use OCA\Forum\Db\Thread;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Service\SearchService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
@@ -26,6 +27,7 @@ class SearchController extends OCSController {
IRequest $request,
private SearchService $searchService,
private ThreadMapper $threadMapper,
private UserService $userService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -87,14 +89,27 @@ class SearchController extends OCSController {
$offset
);
// Enrich threads
$enrichedThreads = array_map(function ($thread) {
return Thread::enrichThread($thread);
// Collect all unique author IDs from both threads and posts
$allAuthorIds = [];
foreach ($results['threads'] as $thread) {
$allAuthorIds[] = $thread->getAuthorId();
}
foreach ($results['posts'] as $post) {
$allAuthorIds[] = $post->getAuthorId();
}
$allAuthorIds = array_unique($allAuthorIds);
// Batch fetch all author data once
$authors = $this->userService->enrichMultipleUsers($allAuthorIds);
// Enrich threads with pre-fetched author data
$enrichedThreads = array_map(function ($thread) use ($authors) {
return Thread::enrichThread($thread, $authors[$thread->getAuthorId()]);
}, $results['threads']);
// Enrich posts (with thread context)
$enrichedPosts = array_map(function ($post) {
$enriched = Post::enrichPostContent($post);
// Enrich posts with pre-fetched author data and thread context
$enrichedPosts = array_map(function ($post) use ($authors) {
$enriched = Post::enrichPostContent($post, [], [], null, $authors[$post->getAuthorId()]);
// Add thread info for context
try {
$thread = $this->threadMapper->find($post->getThreadId());

View File

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

View File

@@ -13,7 +13,10 @@ 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 OCA\Forum\Service\UserService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -32,6 +35,9 @@ class ThreadController extends OCSController {
private CategoryMapper $categoryMapper,
private PostMapper $postMapper,
private UserStatsMapper $userStatsMapper,
private ThreadSubscriptionMapper $threadSubscriptionMapper,
private UserPreferencesService $userPreferencesService,
private UserService $userService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -50,7 +56,17 @@ class ThreadController extends OCSController {
public function index(): DataResponse {
try {
$threads = $this->threadMapper->findAll();
return new DataResponse(array_map(fn ($t) => Thread::enrichThread($t), $threads));
// Extract unique author IDs
$authorIds = array_unique(array_map(fn ($t) => $t->getAuthorId(), $threads));
// Batch fetch author data (includes roles)
$authors = $this->userService->enrichMultipleUsers($authorIds);
// Enrich threads with pre-fetched author data
return new DataResponse(array_map(function ($t) use ($authors) {
return Thread::enrichThread($t, $authors[$t->getAuthorId()]);
}, $threads));
} catch (\Exception $e) {
$this->logger->error('Error fetching threads: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -73,7 +89,17 @@ class ThreadController extends OCSController {
public function byCategory(int $categoryId, int $limit = 50, int $offset = 0): DataResponse {
try {
$threads = $this->threadMapper->findByCategoryId($categoryId, $limit, $offset);
return new DataResponse(array_map(fn ($t) => Thread::enrichThread($t), $threads));
// Extract unique author IDs
$authorIds = array_unique(array_map(fn ($t) => $t->getAuthorId(), $threads));
// Batch fetch author data (includes roles)
$authors = $this->userService->enrichMultipleUsers($authorIds);
// Enrich threads with pre-fetched author data
return new DataResponse(array_map(function ($t) use ($authors) {
return Thread::enrichThread($t, $authors[$t->getAuthorId()]);
}, $threads));
} catch (\Exception $e) {
$this->logger->error('Error fetching threads by category: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -95,7 +121,14 @@ class ThreadController extends OCSController {
public function byAuthor(string $authorId, int $limit = 50, int $offset = 0): DataResponse {
try {
$threads = $this->threadMapper->findByAuthorId($authorId, $limit, $offset);
return new DataResponse(array_map(fn ($t) => Thread::enrichThread($t), $threads));
// For threads by a single author, we can optimize by fetching author data once
$author = $this->userService->enrichUserData($authorId);
// Enrich threads with pre-fetched author data
return new DataResponse(array_map(function ($t) use ($author) {
return Thread::enrichThread($t, $author);
}, $threads));
} catch (\Exception $e) {
$this->logger->error('Error fetching threads by author: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
@@ -244,6 +277,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 +434,18 @@ class ThreadController extends OCSController {
// Don't fail the request if category update fails
}
// Update author's user stats (decrement thread count and all posts in this thread)
try {
$this->userStatsMapper->decrementThreadCount($thread->getAuthorId());
// Decrement post count by the number of posts in this thread
if ($thread->getPostCount() > 0) {
$this->userStatsMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after thread deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
return new DataResponse([
'success' => true,
'categorySlug' => $categorySlug,

View File

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

View File

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

View File

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

View File

@@ -82,6 +82,7 @@ class Post extends Entity implements JsonSerializable {
array $bbcodes = [],
array $reactions = [],
?string $currentUserId = null,
?array $author = null,
): array {
if (!is_array($post)) {
$post = $post->jsonSerialize();
@@ -95,11 +96,13 @@ class Post extends Entity implements JsonSerializable {
}
$post['content'] = $service->parse($post['content'], $bbcodes, $post['authorId'], $post['id']);
// Add author display name (obfuscated if user is deleted)
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
$userData = $userService->enrichUserData($post['authorId']);
$post['authorDisplayName'] = $userData['displayName'];
$post['authorIsDeleted'] = $userData['isDeleted'];
// Add author object (includes display name, deleted status, and roles)
if ($author === null) {
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
$post['author'] = $userService->enrichUserData($post['authorId']);
} else {
$post['author'] = $author;
}
// Add reactions (grouped by emoji)
$post['reactions'] = self::groupReactions($reactions, $currentUserId);

View File

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

View File

@@ -18,6 +18,10 @@ use OCP\AppFramework\Db\Entity;
* @method void setName(string $value)
* @method string|null getDescription()
* @method void setDescription(?string $value)
* @method string|null getColorLight()
* @method void setColorLight(?string $value)
* @method string|null getColorDark()
* @method void setColorDark(?string $value)
* @method bool getCanAccessAdminTools()
* @method void setCanAccessAdminTools(bool $value)
* @method bool getCanEditRoles()
@@ -30,6 +34,8 @@ use OCP\AppFramework\Db\Entity;
class Role extends Entity implements JsonSerializable {
protected $name;
protected $description;
protected $colorLight;
protected $colorDark;
protected $canAccessAdminTools;
protected $canEditRoles;
protected $canEditCategories;
@@ -39,6 +45,8 @@ class Role extends Entity implements JsonSerializable {
$this->addType('id', 'integer');
$this->addType('name', 'string');
$this->addType('description', 'string');
$this->addType('colorLight', 'string');
$this->addType('colorDark', 'string');
$this->addType('canAccessAdminTools', 'boolean');
$this->addType('canEditRoles', 'boolean');
$this->addType('canEditCategories', 'boolean');
@@ -50,6 +58,8 @@ class Role extends Entity implements JsonSerializable {
'id' => $this->getId(),
'name' => $this->getName(),
'description' => $this->getDescription(),
'colorLight' => $this->getColorLight(),
'colorDark' => $this->getColorDark(),
'canAccessAdminTools' => $this->getCanAccessAdminTools(),
'canEditRoles' => $this->getCanEditRoles(),
'canEditCategories' => $this->getCanEditCategories(),

View File

@@ -55,6 +55,46 @@ class RoleMapper extends QBMapper {
return $this->findEntity($qb);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function findByNameCaseInsensitive(string $name): Role {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq(
$qb->func()->lower('name'),
$qb->func()->lower($qb->createNamedParameter($name, IQueryBuilder::PARAM_STR))
)
);
return $this->findEntity($qb);
}
/**
* Find multiple roles by IDs at once
*
* @param array<int> $ids
* @return array<Role>
*/
public function findByIds(array $ids): array {
if (empty($ids)) {
return [];
}
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))
);
return $this->findEntities($qb);
}
/**
* @return array<Role>
*/

View File

@@ -91,16 +91,18 @@ class Thread extends Entity implements JsonSerializable {
];
}
public static function enrichThread(mixed $thread): array {
public static function enrichThread(mixed $thread, ?array $author = null): array {
if (!is_array($thread)) {
$thread = $thread->jsonSerialize();
}
// Add author display name (obfuscated if user is deleted)
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
$userData = $userService->enrichUserData($thread['authorId']);
$thread['authorDisplayName'] = $userData['displayName'];
$thread['authorIsDeleted'] = $userData['isDeleted'];
// Add author object (includes display name, deleted status, and roles)
if ($author === null) {
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
$thread['author'] = $userService->enrichUserData($thread['authorId']);
} else {
$thread['author'] = $author;
}
// Add category information (slug and name) for navigation
try {
@@ -114,6 +116,21 @@ class Thread extends Entity implements JsonSerializable {
$thread['categoryName'] = null;
}
// Add subscription status for the current user
try {
$userSession = \OC::$server->get(\OCP\IUserSession::class);
$user = $userSession->getUser();
if ($user) {
$subscriptionMapper = \OC::$server->get(\OCA\Forum\Db\ThreadSubscriptionMapper::class);
$thread['isSubscribed'] = $subscriptionMapper->isUserSubscribed($user->getUID(), $thread['id']);
} else {
$thread['isSubscribed'] = false;
}
} catch (\Exception $e) {
// If there's an error checking subscription, default to false
$thread['isSubscribed'] = false;
}
return $thread;
}
}

View File

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

View File

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

View File

@@ -69,6 +69,27 @@ class UserRoleMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Find user roles for multiple users at once
*
* @param array<string> $userIds
* @return array<UserRole>
*/
public function findByUserIds(array $userIds): array {
if (empty($userIds)) {
return [];
}
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->in('user_id', $qb->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY))
);
return $this->findEntities($qb);
}
/**
* @return array<UserRole>
*/

View File

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

View File

@@ -123,26 +123,116 @@ class UserStatsMapper extends QBMapper {
}
/**
* Get top contributors by post count
* Get top contributors by total activity (posts + threads)
*
* @return array<array{userId: string, postCount: int}>
* @return array<array{userId: string, postCount: int, threadCount: int}>
*/
public function getTopContributors(int $limit = 10): array {
$qb = $this->db->getQueryBuilder();
$qb->select('user_id', 'post_count')
$qb->select('user_id', 'post_count', 'thread_count')
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'))
->orderBy('post_count', 'DESC')
->setMaxResults($limit);
->andWhere(
$qb->expr()->orX(
$qb->expr()->gt('post_count', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)),
$qb->expr()->gt('thread_count', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))
)
);
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
return array_map(fn ($row) => [
// Calculate total and sort in PHP
$contributors = array_map(fn ($row) => [
'userId' => $row['user_id'],
'postCount' => (int)$row['post_count'],
'threadCount' => (int)$row['thread_count'],
'total' => (int)$row['post_count'] + (int)$row['thread_count'],
], $rows);
// Sort by total descending
usort($contributors, fn ($a, $b) => $b['total'] <=> $a['total']);
// Return top N (remove the total field as it was just for sorting)
return array_slice(array_map(fn ($c) => [
'userId' => $c['userId'],
'postCount' => $c['postCount'],
'threadCount' => $c['threadCount'],
], $contributors), 0, $limit);
}
/**
* Get top contributors for a specific time period by counting posts/threads directly
*
* @return array<array{userId: string, postCount: int, threadCount: int}>
*/
public function getTopContributorsSince(int $timestamp, int $limit = 10): array {
// Count posts per user since timestamp (excluding first posts which are counted as threads)
$postsQb = $this->db->getQueryBuilder();
$postsQb->select('author_id')
->selectAlias($postsQb->func()->count('*'), 'count')
->from(Application::tableName('forum_posts'))
->where($postsQb->expr()->gte('created_at', $postsQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->andWhere($postsQb->expr()->isNull('deleted_at'))
->andWhere($postsQb->expr()->eq('is_first_post', $postsQb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
->groupBy('author_id');
$postsResult = $postsQb->executeQuery();
$postsRows = $postsResult->fetchAll();
$postsResult->closeCursor();
$postsByUser = [];
foreach ($postsRows as $row) {
$postsByUser[$row['author_id']] = (int)$row['count'];
}
// Count threads per user since timestamp
$threadsQb = $this->db->getQueryBuilder();
$threadsQb->select('author_id')
->selectAlias($threadsQb->func()->count('*'), 'count')
->from(Application::tableName('forum_threads'))
->where($threadsQb->expr()->gte('created_at', $threadsQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->andWhere($threadsQb->expr()->isNull('deleted_at'))
->groupBy('author_id');
$threadsResult = $threadsQb->executeQuery();
$threadsRows = $threadsResult->fetchAll();
$threadsResult->closeCursor();
$threadsByUser = [];
foreach ($threadsRows as $row) {
$threadsByUser[$row['author_id']] = (int)$row['count'];
}
// Combine and calculate totals
$allUserIds = array_unique(array_merge(array_keys($postsByUser), array_keys($threadsByUser)));
$contributors = [];
foreach ($allUserIds as $userId) {
$postCount = $postsByUser[$userId] ?? 0;
$threadCount = $threadsByUser[$userId] ?? 0;
$total = $postCount + $threadCount;
if ($total > 0) {
$contributors[] = [
'userId' => $userId,
'postCount' => $postCount,
'threadCount' => $threadCount,
'total' => $total,
];
}
}
// Sort by total descending
usort($contributors, fn ($a, $b) => $b['total'] <=> $a['total']);
// Return top N (remove the total field as it was just for sorting)
return array_slice(array_map(fn ($c) => [
'userId' => $c['userId'],
'postCount' => $c['postCount'],
'threadCount' => $c['threadCount'],
], $contributors), 0, $limit);
}
/**

View File

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

View File

@@ -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,15 +536,25 @@ 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);
$l = \OC::$server->getL10N('forum');
$timestamp = time();
// Check if data has already been seeded by looking for the Admin role
// Check if data has already been seeded by looking for id 1
$qb = $db->getQueryBuilder();
$qb->select('id')
->from('forum_roles')
->where($qb->expr()->eq('name', $qb->createNamedParameter('Admin')));
->where($qb->expr()->eq('id', $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
$result = $qb->executeQuery();
$exists = $result->fetch();
$result->closeCursor();
@@ -553,13 +564,21 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
return;
}
// Create default roles
// Find first admin user (fallback to 'admin' if no admin users found)
$adminUserId = 'admin';
$userManager->callForSeenUsers(function ($user) use ($groupManager, &$adminUserId) {
if ($groupManager->isAdmin($user->getUID())) {
$adminUserId = $user->getUID();
return false; // Stop iteration after finding first admin
}
});
// Create default roles
$qb = $db->getQueryBuilder();
$qb->insert('forum_roles')
->values([
'name' => $qb->createNamedParameter('Admin'),
'description' => $qb->createNamedParameter('Administrator role with full permissions'),
'name' => $qb->createNamedParameter($l->t('Admin')),
'description' => $qb->createNamedParameter($l->t('Administrator role with full permissions')),
'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),
@@ -571,8 +590,8 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$qb = $db->getQueryBuilder();
$qb->insert('forum_roles')
->values([
'name' => $qb->createNamedParameter('Moderator'),
'description' => $qb->createNamedParameter('Moderator role with elevated permissions'),
'name' => $qb->createNamedParameter($l->t('Moderator')),
'description' => $qb->createNamedParameter($l->t('Moderator role with elevated permissions')),
'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),
@@ -584,8 +603,8 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$qb = $db->getQueryBuilder();
$qb->insert('forum_roles')
->values([
'name' => $qb->createNamedParameter('User'),
'description' => $qb->createNamedParameter('Default user role with basic permissions'),
'name' => $qb->createNamedParameter($l->t('User')),
'description' => $qb->createNamedParameter($l->t('Default user role with basic permissions')),
'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),
@@ -598,8 +617,8 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$qb = $db->getQueryBuilder();
$qb->insert('forum_cat_headers')
->values([
'name' => $qb->createNamedParameter('General'),
'description' => $qb->createNamedParameter('General discussion categories'),
'name' => $qb->createNamedParameter($l->t('General')),
'description' => $qb->createNamedParameter($l->t('General discussion categories')),
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
@@ -611,8 +630,8 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$qb->insert('forum_categories')
->values([
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'name' => $qb->createNamedParameter('General Discussions'),
'description' => $qb->createNamedParameter('A place for general conversations and discussions'),
'name' => $qb->createNamedParameter($l->t('General Discussions')),
'description' => $qb->createNamedParameter($l->t('A place for general conversations and discussions')),
'slug' => $qb->createNamedParameter('general-discussions'),
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
@@ -628,10 +647,10 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$qb->insert('forum_categories')
->values([
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'name' => $qb->createNamedParameter('Support'),
'description' => $qb->createNamedParameter('Ask questions about the forum, provide feedback or report issues.'),
'name' => $qb->createNamedParameter($l->t('Support')),
'description' => $qb->createNamedParameter($l->t('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),
@@ -685,15 +704,12 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
}
// 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',
'replacement' => '<code>{content}</code>',
'example' => '[icode]inline code[/icode]',
'description' => 'Inline code',
'description' => $l->t('Inline code'),
'parse_inner' => false,
'is_builtin' => true,
'special_handler' => null,
@@ -702,7 +718,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'tag' => 'spoiler',
'replacement' => '<details><summary>{title}</summary>{content}</details>',
'example' => '[spoiler="Spoiler Title"]Hidden content[/spoiler]',
'description' => 'Spoilers',
'description' => $l->t('Spoilers'),
'parse_inner' => false,
'is_builtin' => true,
'special_handler' => null,
@@ -711,7 +727,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'tag' => 'attachment',
'replacement' => '[attachment]/file/path.txt[/attachment]',
'example' => '',
'description' => 'Attachment',
'description' => $l->t('Attachment'),
'parse_inner' => false,
'is_builtin' => true,
'special_handler' => 'attachment',
@@ -736,12 +752,9 @@ 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) {
$userManager->callForAllUsers(function ($user) use ($db, $timestamp, $userRoleId, $adminRoleId, $groupManager) {
$userId = $user->getUID();
$isAdmin = $adminGroup && $adminGroup->inGroup($user);
$isAdmin = $groupManager->isAdmin($userId);
// Assign User role to all users
$qb = $db->getQueryBuilder();
@@ -771,8 +784,8 @@ 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'),
'title' => $qb->createNamedParameter('Welcome to Nextcloud Forums'),
'author_id' => $qb->createNamedParameter($adminUserId),
'title' => $qb->createNamedParameter($l->t('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),
@@ -787,31 +800,31 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$threadId = $qb->getLastInsertId();
// Create welcome post
$welcomeContent = "Welcome to the Nextcloud Forums!\n\n"
. 'This is a community-driven forum built right into your Nextcloud instance. '
. "Here you can discuss topics, share ideas, and collaborate with other users.\n\n"
. "[b]Features:[/b]\n"
$welcomeContent = $l->t('Welcome to the Nextcloud Forums!') . "\n\n"
. $l->t('This is a community-driven forum built right into your Nextcloud instance. '
. 'Here you can discuss topics, share ideas, and collaborate with other users.') . "\n\n"
. '[b]' . $l->t('Features:') . "[/b]\n"
. "[list]\n"
. "[*]Create and reply to threads\n"
. "[*]Organize discussions by categories\n"
. "[*]Use BBCode for rich text formatting\n"
. "[*]Attach files from your Nextcloud storage\n"
. "[*]React to posts\n"
. "[*]Track read/unread threads\n\n"
. '[*]' . $l->t('Create and reply to threads') . "\n"
. '[*]' . $l->t('Organize discussions by categories') . "\n"
. '[*]' . $l->t('Use BBCode for rich text formatting') . "\n"
. '[*]' . $l->t('Attach files from your Nextcloud storage') . "\n"
. '[*]' . $l->t('React to posts') . "\n"
. '[*]' . $l->t('Track read/unread threads') . "\n\n"
. "[/list]\n"
. "[b]BBCode Examples:[/b]\n"
. '[b]' . $l->t('BBCode Examples:') . "[/b]\n"
. "[list]\n"
. "[*][b]Bold text[/b] - Use [icode][b]text[/b][/icode]\n"
. "[*][i]Italic text[/i] - Use [icode][i]text[/i][/icode]\n"
. "[*][u]Underlined text[/u] - Use [icode][u]text[/u][/icode]\n\n"
. '[*][b]' . $l->t('Bold text') . '[/b] - ' . $l->t('Use [icode][b]text[/b][/icode]') . "\n"
. '[*][i]' . $l->t('Italic text') . '[/i] - ' . $l->t('Use [icode][i]text[/i][/icode]') . "\n"
. '[*][u]' . $l->t('Underlined text') . '[/u] - ' . $l->t('Use [icode][u]text[/u][/icode]') . "\n\n"
. "[/list]\n"
. 'Feel free to start a new discussion or reply to existing threads. Happy posting!';
. $l->t('Feel free to start a new discussion or reply to existing threads. Happy posting!');
$qb = $db->getQueryBuilder();
$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(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
@@ -830,11 +843,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 +856,5 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
])
->executeStatement();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version4Date20251120210339 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
// Add color columns to forum_roles table
if ($schema->hasTable('forum_roles')) {
$table = $schema->getTable('forum_roles');
if (!$table->hasColumn('color_light')) {
$table->addColumn('color_light', 'string', [
'notnull' => false,
'length' => 7,
'default' => null,
]);
}
if (!$table->hasColumn('color_dark')) {
$table->addColumn('color_dark', 'string', [
'notnull' => false,
'length' => 7,
'default' => null,
]);
}
}
return $schema;
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$this->updateDefaultRoleColors();
}
/**
* Update default roles with colors that are legible in both light and dark modes
*/
private function updateDefaultRoleColors(): void {
$db = \OC::$server->get(\OCP\IDBConnection::class);
// Define colors for default roles
// Light mode uses darker colors, dark mode uses lighter colors for better contrast
$roleColors = [
1 => [
'light' => '#dc2626', // Red 600 - Admin
'dark' => '#f87171', // Red 400
],
2 => [
'light' => '#2563eb', // Blue 600 - Moderator
'dark' => '#60a5fa', // Blue 400
],
3 => [
'light' => '#059669', // Emerald 600 - User
'dark' => '#34d399', // Emerald 400
],
];
foreach ($roleColors as $roleId => $colors) {
$qb = $db->getQueryBuilder();
$qb->update('forum_roles')
->set('color_light', $qb->createNamedParameter($colors['light']))
->set('color_dark', $qb->createNamedParameter($colors['dark']))
->where($qb->expr()->eq('id', $qb->createNamedParameter($roleId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->executeStatement();
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ declare(strict_types=1);
namespace OCA\Forum\Service;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\UserRoleMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IUserManager;
@@ -19,6 +21,8 @@ class UserService {
public function __construct(
private IUserManager $userManager,
private UserStatsMapper $userStatsMapper,
private RoleMapper $roleMapper,
private UserRoleMapper $userRoleMapper,
) {
}
@@ -58,18 +62,35 @@ class UserService {
}
/**
* Enrich user data with display name and deleted status
* Enrich user data with display name, deleted status, and roles
*
* @return array{userId: string, displayName: string, isDeleted: bool}
* @param string $userId
* @param array|null $roles Optional pre-fetched roles array
* @return array{userId: string, displayName: string, isDeleted: bool, roles: array}
*/
public function enrichUserData(string $userId): array {
public function enrichUserData(string $userId, ?array $roles = null): array {
$isDeleted = $this->isUserDeleted($userId);
$displayName = $this->getUserDisplayName($userId);
// If roles not provided, fetch them
if ($roles === null) {
$userRoles = $this->userRoleMapper->findByUserId($userId);
$roles = [];
foreach ($userRoles as $userRole) {
try {
$role = $this->roleMapper->find($userRole->getRoleId());
$roles[] = $role->jsonSerialize();
} catch (\Exception $e) {
// Role not found, skip
}
}
}
return [
'userId' => $userId,
'displayName' => $displayName,
'isDeleted' => $isDeleted,
'roles' => $roles,
];
}
@@ -77,13 +98,82 @@ class UserService {
* Enrich multiple users at once (for performance)
*
* @param array<string> $userIds
* @return array<string, array{userId: string, displayName: string, isDeleted: bool}>
* @param array<string, array> $rolesMap Optional pre-fetched roles map (userId => roles[])
* @return array<string, array{userId: string, displayName: string, isDeleted: bool, roles: array}>
*/
public function enrichMultipleUsers(array $userIds): array {
public function enrichMultipleUsers(array $userIds, ?array $rolesMap = null): array {
$result = [];
// If roles not provided, fetch them all at once
if ($rolesMap === null) {
$rolesMap = $this->fetchRolesForUsers($userIds);
}
foreach ($userIds as $userId) {
$result[$userId] = $this->enrichUserData($userId);
$isDeleted = $this->isUserDeleted($userId);
$displayName = $this->getUserDisplayName($userId);
$result[$userId] = [
'userId' => $userId,
'displayName' => $displayName,
'isDeleted' => $isDeleted,
'roles' => $rolesMap[$userId] ?? [],
];
}
return $result;
}
/**
* Fetch roles for multiple users efficiently
*
* @param array<string> $userIds
* @return array<string, array> Map of userId => roles[]
*/
private function fetchRolesForUsers(array $userIds): array {
if (empty($userIds)) {
return [];
}
$rolesMap = [];
// Initialize all user IDs with empty arrays
foreach ($userIds as $userId) {
$rolesMap[$userId] = [];
}
// Fetch all user roles for these users
$userRoles = $this->userRoleMapper->findByUserIds($userIds);
// Group by user ID and fetch role details
$roleIds = [];
$userRolesByUser = [];
foreach ($userRoles as $userRole) {
$userId = $userRole->getUserId();
$roleId = $userRole->getRoleId();
if (!isset($userRolesByUser[$userId])) {
$userRolesByUser[$userId] = [];
}
$userRolesByUser[$userId][] = $roleId;
$roleIds[$roleId] = true;
}
// Fetch all roles at once
$roles = [];
$roleEntities = $this->roleMapper->findByIds(array_keys($roleIds));
foreach ($roleEntities as $role) {
$roles[$role->getId()] = $role->jsonSerialize();
}
// Map roles to users
foreach ($userRolesByUser as $userId => $userRoleIds) {
foreach ($userRoleIds as $roleId) {
if (isset($roles[$roleId])) {
$rolesMap[$userId][] = $roles[$roleId];
}
}
}
return $rolesMap;
}
}

View File

@@ -5756,6 +5756,18 @@
"default": null,
"description": "Role description"
},
"colorLight": {
"type": "string",
"nullable": true,
"default": null,
"description": "Light mode color"
},
"colorDark": {
"type": "string",
"nullable": true,
"default": null,
"description": "Dark mode color"
},
"canAccessAdminTools": {
"type": "boolean",
"default": false,
@@ -5987,6 +5999,18 @@
"default": null,
"description": "Role description"
},
"colorLight": {
"type": "string",
"nullable": true,
"default": null,
"description": "Light mode color"
},
"colorDark": {
"type": "string",
"nullable": true,
"default": null,
"description": "Dark mode color"
},
"canAccessAdminTools": {
"type": "boolean",
"nullable": true,
@@ -6630,6 +6654,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 +7928,715 @@
}
}
},
"/ocs/v2.php/apps/forum/api/threads/{threadId}/subscribe": {
"post": {
"operationId": "thread_subscription-subscribe",
"summary": "Subscribe current user to a thread to receive notifications",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User subscribed to thread",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "thread_subscription-unsubscribe",
"summary": "Unsubscribe current user from a thread",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User unsubscribed from thread",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"get": {
"operationId": "thread_subscription-is-subscribed",
"summary": "Check if current user is subscribed to a thread",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Subscription status returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/thread-subscriptions": {
"get": {
"operationId": "thread_subscription-get-user-subscriptions",
"summary": "Get all threads the current user is subscribed to",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Thread subscriptions returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/user-preferences": {
"get": {
"operationId": "user_preferences-index",
"summary": "Get all user preferences",
"tags": [
"user_preferences"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Preferences returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "User not authenticated",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
},
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
]
}
}
}
}
}
},
"put": {
"operationId": "user_preferences-update",
"summary": "Update user preferences",
"tags": [
"user_preferences"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"preferences"
],
"properties": {
"preferences": {
"type": "object",
"description": "Key-value pairs of preferences to update",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Preferences updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "User not authenticated",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
},
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
]
}
}
}
},
"400": {
"description": "Invalid preference key or value",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/users/{userId}/roles": {
"get": {
"operationId": "user_role-by-user",

View File

@@ -9,7 +9,7 @@
},
"scripts": {
"dev": "vite build --watch",
"build": "vue-tsc -b && vite build",
"build": "vite build",
"lint": "eslint src",
"format": "eslint --fix src && prettier --write {vite.config.ts,src/,README.md}",
"prepare": "husky",
@@ -25,7 +25,7 @@
"@nextcloud/l10n": "^3.4.1",
"@nextcloud/router": "^3.1.0",
"@nextcloud/vite-config": "2.3.5",
"@nextcloud/vue": "^9.2.0",
"@nextcloud/vue": "^9.3.0",
"date-fns": "^4.1.0",
"linkifyjs": "^4.3.2",
"vue": "^3.5.24",
@@ -39,14 +39,16 @@
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.1",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"lint-staged": "^16.2.7",
"prettier": "^2.8.8",
"prettier-plugin-vue": "^1.1.6",
"sass": "^1.94.0",
"rollup-plugin-visualizer": "^6.0.5",
"sass": "^1.94.2",
"sass-embedded": "^1.93.3",
"typescript": "5.9.2",
"typescript-eslint": "^8.46.4",
"typescript-eslint": "^8.47.0",
"vite": "^6.4.1",
"vite-plugin-checker": "^0.11.0",
"vue-router": "^4.6.3",
"vue-tsc": "^2.2.12"
}

761
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 320 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,30 +2,24 @@
<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.author?.userId || post.authorId"
:display-name="post.author?.displayName || post.authorId"
:is-deleted="post.author?.isDeleted || false"
:avatar-size="32"
:roles="post.author?.roles || []"
>
<template #meta>
<div class="post-meta">
<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 +71,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 +114,14 @@ export default defineComponent({
},
},
emits: ['reply', 'edit', 'delete', 'update'],
setup() {
const { isAdmin, isModerator } = useUserRole()
return {
isAdmin,
isModerator,
}
},
data() {
return {
isEditing: false,
@@ -140,11 +143,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 +172,6 @@ export default defineComponent({
}
},
navigateToProfile() {
this.$router.push(`/u/${this.post.authorId}`)
},
handleReply() {
this.closeActionsMenu()
this.$emit('reply', this.post)
@@ -274,46 +282,12 @@ export default defineComponent({
align-items: flex-start;
gap: 12px;
flex: 1;
}
.author-details {
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
.unread-indicator {
position: absolute;
left: -14px;
top: 6px;
}
}
.avatar-link {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
.author-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
&.author-name-link {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-primary-element);
}
}
&.deleted-user {
font-style: italic;
opacity: 0.7;
left: 0;
top: 8px;
}
}

View File

@@ -114,7 +114,7 @@ export default defineComponent({
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
gap: 12px;
}
.hint {

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
<template>
<span class="role-badge" :class="densityClass" :style="badgeStyle">
{{ role.name }}
</span>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import { isDarkTheme } from '@nextcloud/vue/functions/isDarkTheme'
import type { Role } from '@/types'
export default defineComponent({
name: 'RoleBadge',
props: {
role: {
type: Object as PropType<Role>,
required: true,
},
density: {
type: String as PropType<'normal' | 'compact'>,
default: 'normal',
validator: (value: string) => ['normal', 'compact'].includes(value),
},
},
computed: {
densityClass(): string {
return `density-${this.density}`
},
backgroundColor(): string {
const isDark = isDarkTheme
const color = isDark ? this.role.colorDark : this.role.colorLight
if (color) {
return color
}
// Fallback colors for system roles
const fallbackColors: Record<number, { light: string; dark: string }> = {
1: { light: '#dc2626', dark: '#f87171' }, // Admin - red
2: { light: '#2563eb', dark: '#60a5fa' }, // Moderator - blue
3: { light: '#059669', dark: '#34d399' }, // User - emerald
}
const fallback = fallbackColors[this.role.id]
if (fallback) {
return isDark ? fallback.dark : fallback.light
}
// Default fallback
return isDark ? '#ffffff' : '#000000'
},
textColor(): string {
// Calculate luminance to determine if text should be black or white
const color = this.backgroundColor
const rgb = this.hexToRgb(color)
if (!rgb) {
return '#ffffff'
}
// Calculate relative luminance using WCAG formula
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
// If luminance > 0.5, use dark text, otherwise use light text
return luminance > 0.5 ? '#000000' : '#ffffff'
},
badgeStyle(): Record<string, string> {
return {
backgroundColor: this.backgroundColor,
color: this.textColor,
}
},
},
methods: {
hexToRgb(hex: string): { r: number; g: number; b: number } | null {
// Remove # if present
hex = hex.replace(/^#/, '')
// Parse hex to RGB
if (hex.length === 3) {
// Convert shorthand (e.g., #fff) to full form
hex = hex
.split('')
.map((char) => char + char)
.join('')
}
const bigint = parseInt(hex, 16)
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255,
}
},
},
})
</script>
<style scoped lang="scss">
.role-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
line-height: 1.4;
&.density-compact {
padding: 2px 8px;
border-radius: 8px;
font-size: 0.75rem;
line-height: 1.3;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="search-post-result" @click="navigateToPost">
<div class="search-post-result" :class="{ 'dark-theme': isDarkTheme }" @click="navigateToPost">
<div class="result-header">
<div class="thread-context">
<span class="meta-label">{{ strings.inThread }}:</span>
@@ -20,7 +20,7 @@
<div class="result-meta">
<span class="meta-item author">
<AccountIcon :size="16" />
{{ post.authorDisplayName || strings.deletedUser }}
{{ post.author?.displayName || strings.deletedUser }}
</span>
<span class="meta-item time">
<ClockIcon :size="16" />
@@ -39,6 +39,7 @@ import { defineComponent, type PropType } from 'vue'
import type { Post } from '@/types'
import { t } from '@nextcloud/l10n'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import { isDarkTheme } from '@nextcloud/vue/functions/isDarkTheme'
import AccountIcon from '@icons/Account.vue'
import ClockIcon from '@icons/Clock.vue'
import PencilIcon from '@icons/Pencil.vue'
@@ -63,6 +64,7 @@ export default defineComponent({
},
data() {
return {
isDarkTheme,
strings: {
inThread: t('forum', 'In thread'),
threadUnavailable: t('forum', 'Thread unavailable'),
@@ -221,14 +223,23 @@ export default defineComponent({
font-size: 0.9375rem;
:deep(mark) {
background: var(--color-primary-element-light);
color: var(--color-primary-element-text);
background: #ffc107;
color: #000;
padding: 2px 4px;
border-radius: 3px;
font-weight: 700;
}
}
&.dark-theme {
.post-content {
:deep(mark) {
background: #ff9800;
color: #fff;
}
}
}
.result-meta {
display: flex;
flex-wrap: wrap;

View File

@@ -1,11 +1,15 @@
<template>
<div class="search-thread-result" @click="$emit('click')">
<div class="search-thread-result" :class="{ 'dark-theme': isDarkTheme }" @click="$emit('click')">
<div class="result-header">
<h4 class="thread-title" v-html="highlightedTitle"></h4>
<div class="thread-badges">
<span v-if="thread.isPinned" class="badge badge-pinned">{{ strings.pinned }}</span>
<span v-if="thread.isLocked" class="badge badge-locked">{{ strings.locked }}</span>
</div>
<h4 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="16" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<LockIcon :size="16" />
</span>
<span v-html="highlightedTitle"></span>
</h4>
</div>
<div class="result-meta">
@@ -15,7 +19,7 @@
</span>
<span class="meta-item author">
<AccountIcon :size="16" />
{{ thread.authorDisplayName || strings.deletedUser }}
{{ thread.author?.displayName || strings.deletedUser }}
</span>
<span class="meta-item">
<MessageIcon :size="16" />
@@ -38,11 +42,14 @@ import { defineComponent, type PropType } from 'vue'
import type { Thread } from '@/types'
import { n, t } from '@nextcloud/l10n'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import { isDarkTheme } from '@nextcloud/vue/functions/isDarkTheme'
import FolderIcon from '@icons/Folder.vue'
import AccountIcon from '@icons/Account.vue'
import MessageIcon from '@icons/Message.vue'
import EyeIcon from '@icons/Eye.vue'
import ClockIcon from '@icons/Clock.vue'
import PinIcon from '@icons/Pin.vue'
import LockIcon from '@icons/Lock.vue'
export default defineComponent({
name: 'SearchThreadResult',
@@ -53,6 +60,8 @@ export default defineComponent({
MessageIcon,
EyeIcon,
ClockIcon,
PinIcon,
LockIcon,
},
props: {
thread: {
@@ -67,9 +76,10 @@ export default defineComponent({
emits: ['click'],
data() {
return {
isDarkTheme,
strings: {
pinned: t('forum', 'Pinned'),
locked: t('forum', 'Locked'),
pinned: t('forum', 'Pinned thread'),
locked: t('forum', 'Locked thread'),
uncategorized: t('forum', 'Uncategorized'),
deletedUser: t('forum', 'Deleted User'),
},
@@ -168,10 +178,6 @@ export default defineComponent({
}
.result-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
.thread-title {
@@ -179,40 +185,41 @@ export default defineComponent({
font-size: 1.125rem;
font-weight: 600;
color: var(--color-main-text);
flex: 1;
line-height: 1.4;
display: flex;
align-items: center;
gap: 6px;
.badge {
font-size: 0.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
&.badge-pinned {
opacity: 0.9;
}
&.badge-locked {
opacity: 0.8;
}
}
:deep(mark) {
background: var(--color-primary-element-light);
color: var(--color-primary-element-text);
background: #ffc107;
color: #000;
padding: 2px 4px;
border-radius: 3px;
font-weight: 700;
}
}
}
.thread-badges {
display: flex;
gap: 6px;
flex-shrink: 0;
.badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
&.badge-pinned {
background: var(--color-primary-element-light);
color: var(--color-primary-element-text);
}
&.badge-locked {
background: var(--color-warning);
color: var(--color-main-background);
}
&.dark-theme {
.thread-title {
:deep(mark) {
background: #ff9800;
color: #fff;
}
}
}

View File

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

View File

@@ -18,23 +18,20 @@
</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.author?.userId || thread.authorId"
:display-name="thread.author?.displayName || thread.authorId"
:is-deleted="thread.author?.isDeleted || false"
:avatar-size="32"
:roles="thread.author?.roles || []"
:show-roles="false"
layout="inline"
@click.stop
>
<template #meta>
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</template>
</UserInfo>
</div>
</div>
@@ -61,6 +58,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 +70,7 @@ export default defineComponent({
name: 'ThreadCard',
components: {
NcDateTime,
UserInfo,
PinIcon,
LockIcon,
CommentIcon,
@@ -90,7 +89,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 +97,6 @@ export default defineComponent({
},
}
},
methods: {
navigateToProfile() {
this.$router.push(`/u/${this.thread.authorId}`)
},
},
})
</script>
@@ -120,10 +113,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 +137,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 +204,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 +219,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 +256,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 +270,10 @@ export default defineComponent({
.thread-stats {
flex-direction: row;
flex-wrap: wrap;
width: 100%;
justify-content: flex-start;
gap: 8px;
}
}
</style>

View File

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

View File

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

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

@@ -0,0 +1,205 @@
<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>
<div v-if="showRoles && displayRoles.length > 0" class="role-badges">
<RoleBadge v-for="role in displayRoles" :key="role.id" :role="role" density="compact" />
</div>
<template v-if="layout === 'inline'">
<span class="meta-separator">·</span>
<span class="meta-content">
<slot name="meta"></slot>
</span>
</template>
</div>
<slot v-if="layout !== 'inline'" name="meta"></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import UserAvatar from './UserAvatar.vue'
import RoleBadge from './RoleBadge.vue'
import type { Role } from '@/types'
export default defineComponent({
name: 'UserInfo',
components: {
UserAvatar,
RoleBadge,
},
props: {
userId: {
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),
},
roles: {
type: Array as PropType<Role[]>,
default: () => [],
},
showRoles: {
type: Boolean,
default: true,
},
},
computed: {
isClickable(): boolean {
return this.clickable && !this.isDeleted
},
displayRoles(): Role[] {
if (!this.roles || this.roles.length === 0) {
return []
}
// Define default role IDs and their precedence
const defaultRoleIds = [1, 2, 3] // Admin (1), Moderator (2), User (3)
const rolePrecedence: Record<number, number> = {
1: 1, // Admin - highest priority
2: 2, // Moderator - medium priority
3: 3, // User - lowest priority
}
// Separate default and custom roles
const defaultRoles = this.roles.filter((role) => defaultRoleIds.includes(role.id))
const customRoles = this.roles.filter((role) => !defaultRoleIds.includes(role.id))
// Find the most prominent default role
let primaryDefaultRole: Role | null = null
if (defaultRoles.length > 0) {
primaryDefaultRole = defaultRoles.reduce((mostProminent, currentRole) => {
const currentPrecedence = rolePrecedence[currentRole.id] || 999
const prominentPrecedence = rolePrecedence[mostProminent.id] || 999
return currentPrecedence < prominentPrecedence ? currentRole : mostProminent
})
}
// Build the display list: primary default role + all custom roles
const result: Role[] = []
if (primaryDefaultRole) {
result.push(primaryDefaultRole)
}
result.push(...customRoles)
return result
},
},
methods: {
handleNameClick(event: MouseEvent): void {
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;
flex-wrap: wrap;
}
.role-badges {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
&.clickable {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-primary-element);
}
}
&.deleted-user {
font-style: italic;
opacity: 0.7;
}
}
.meta-separator {
color: var(--color-text-maxcontrast);
opacity: 0.5;
font-size: 0.85rem;
}
.meta-content {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
</style>

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,13 @@ export interface CategoryHeader {
categories?: Category[]
}
export interface User {
userId: string
displayName: string
isDeleted: boolean
roles: Role[]
}
export interface Thread {
id: number
categoryId: number
@@ -39,11 +46,11 @@ export interface Thread {
isHidden: boolean
createdAt: number
updatedAt: number
// Enriched fields (added by Thread::enrichThreadAuthor)
authorDisplayName?: string
authorIsDeleted?: boolean
// Enriched fields
author?: User
categorySlug?: string | null
categoryName?: string | null
isSubscribed?: boolean
}
export interface Post {
@@ -58,9 +65,8 @@ export interface Post {
editedAt: number | null
createdAt: number
updatedAt: number
// Enriched fields (added by Post::enrichPostContent)
authorDisplayName?: string
authorIsDeleted?: boolean
// Enriched fields
author?: User
// Thread context (added by SearchController for search results)
threadTitle?: string
threadSlug?: string
@@ -108,6 +114,8 @@ export interface Role {
id: number
name: string
description: string | null
colorLight: string | null
colorDark: string | null
canAccessAdminTools: boolean
canEditRoles: boolean
canEditCategories: boolean

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,283 @@
<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 :disabled="saving || !hasChanges" @click="resetForm">
{{ strings.cancel }}
</NcButton>
<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>
</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;
justify-content: flex-end;
gap: 12px;
align-items: center;
}
.success-message {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 16px;
background: var(--color-success-light);
color: var(--color-success-dark);
border-radius: 6px;
font-weight: 500;
}
}
}
</style>

View File

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

View File

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

View File

@@ -1,112 +1,56 @@
<template>
<div class="admin-category-list">
<div class="page-header">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
<PageWrapper>
<template #toolbar>
<AppToolbar>
<template #right>
<NcButton @click="createHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createHeader }}
</NcButton>
<NcButton variant="primary" @click="createCategory">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createCategory }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="admin-category-list">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<div class="header-actions">
<NcButton @click="createHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createHeader }}
</NcButton>
<NcButton variant="primary" @click="createCategory">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createCategory }}
</NcButton>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Category list -->
<div v-else class="category-list">
<!-- Categories by Header -->
<section class="categories-section">
<template v-for="(header, headerIndex) in categoryHeaders" :key="header.id">
<div class="header-row">
<div class="header-sort-buttons">
<NcButton
v-if="headerIndex > 0"
variant="tertiary"
@click="moveHeaderUp(headerIndex)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="headerIndex < categoryHeaders.length - 1"
variant="tertiary"
@click="moveHeaderDown(headerIndex)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="header-info">
<h3>{{ header.name }}</h3>
<span v-if="header.description" class="muted">{{ header.description }}</span>
<span class="muted category-count"
>{{ header.categories?.length || 0 }} {{ strings.categoriesCount }}</span
>
</div>
<div class="header-actions">
<NcButton @click="editHeaderById(header.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton
variant="error"
:disabled="categoryHeaders.length <= 1"
@click="confirmDeleteHeader(header)"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div
v-for="(category, index) in header.categories"
:key="category.id"
class="category-row"
>
<div class="category-sort-buttons">
<!-- Category list -->
<div v-else class="category-list">
<!-- Categories by Header -->
<section class="categories-section">
<template v-for="(header, headerIndex) in categoryHeaders" :key="header.id">
<div class="header-row">
<div class="header-sort-buttons">
<NcButton
v-if="index > 0"
v-if="headerIndex > 0"
variant="tertiary"
@click="moveCategoryUp(header.id, index)"
@click="moveHeaderUp(headerIndex)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
@@ -115,9 +59,9 @@
</template>
</NcButton>
<NcButton
v-if="index < header.categories.length - 1"
v-if="headerIndex < categoryHeaders.length - 1"
variant="tertiary"
@click="moveCategoryDown(header.id, index)"
@click="moveHeaderDown(headerIndex)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
@@ -126,27 +70,25 @@
</template>
</NcButton>
</div>
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">
{{ category.description }}
</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>Threads: {{ category.threadCount || 0 }}</span>
<span></span>
<span>Posts: {{ category.postCount || 0 }}</span>
</div>
<div class="header-info">
<h3>{{ header.name }}</h3>
<span v-if="header.description" class="muted">{{ header.description }}</span>
<span class="muted category-count"
>{{ header.categories?.length || 0 }} {{ strings.categoriesCount }}</span
>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<div class="header-actions">
<NcButton @click="editHeaderById(header.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(category)">
<NcButton
variant="error"
:disabled="categoryHeaders.length <= 1"
@click="confirmDeleteHeader(header)"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
@@ -154,201 +96,272 @@
</NcButton>
</div>
</div>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
</div>
</template>
</section>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
<div v-if="deleteDialog.threadCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.threadWarning(deleteDialog.threadCount) }}</span>
</div>
<div v-if="deleteDialog.threadCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithThreads }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="migrate"
type="radio"
name="delete-action"
>
{{ strings.migrateThreads }}
</NcCheckboxRadioSwitch>
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetCategory }}</label>
<NcSelect
v-model="selectedTargetCategory"
:options="targetCategoryOptions"
:placeholder="strings.selectCategory"
label="label"
track-by="id"
/>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div
v-for="(category, index) in header.categories"
:key="category.id"
class="category-row"
>
<div class="category-sort-buttons">
<NcButton
v-if="index > 0"
variant="tertiary"
@click="moveCategoryUp(header.id, index)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="index < header.categories.length - 1"
variant="tertiary"
@click="moveCategoryDown(header.id, index)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">
{{ category.description }}
</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>Threads: {{ category.threadCount || 0 }}</span>
<span></span>
<span>Posts: {{ category.postCount || 0 }}</span>
</div>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(category)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="delete"
type="radio"
name="delete-action"
>
{{ strings.softDeleteThreads }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="deleteDialog.action === 'migrate' && !selectedTargetCategory"
@click="executeDelete"
>
{{ strings.deleteCategory }}
</NcButton>
</template>
</NcDialog>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcTextField
v-model.number="headerDialog.sortOrder"
:label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
<!-- Header Delete Confirmation Dialog -->
<NcDialog
v-if="deleteHeaderDialog.show"
:name="strings.deleteHeaderTitle"
@close="deleteHeaderDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.headerCategoryWarning(deleteHeaderDialog.categoryCount) }}</span>
</div>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithCategories }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="migrate"
type="radio"
name="delete-header-action"
>
{{ strings.migrateCategories }}
</NcCheckboxRadioSwitch>
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetHeader }}</label>
<NcSelect
v-model="selectedTargetHeader"
:options="targetHeaderOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="delete"
type="radio"
name="delete-header-action"
>
{{ strings.deleteCategories }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
</div>
</div>
</section>
</div>
<template #actions>
<NcButton @click="deleteHeaderDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="deleteHeaderDialog.action === 'migrate' && !selectedTargetHeader"
@click="executeDeleteHeader"
>
{{ strings.deleteHeader }}
</NcButton>
</template>
</NcDialog>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
<div v-if="deleteDialog.threadCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.threadWarning(deleteDialog.threadCount) }}</span>
</div>
<div v-if="deleteDialog.threadCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithThreads }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="migrate"
type="radio"
name="delete-action"
>
{{ strings.migrateThreads }}
</NcCheckboxRadioSwitch>
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetCategory }}</label>
<NcSelect
v-model="selectedTargetCategory"
:options="targetCategoryOptions"
:placeholder="strings.selectCategory"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="delete"
type="radio"
name="delete-action"
>
{{ strings.softDeleteThreads }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="
deleteDialog.action === 'migrate' &&
deleteDialog.threadCount > 0 &&
!selectedTargetCategory
"
@click="executeDelete"
>
{{ strings.deleteCategory }}
</NcButton>
</template>
</NcDialog>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcTextField
v-model.number="headerDialog.sortOrder"
:label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
<!-- Header Delete Confirmation Dialog -->
<NcDialog
v-if="deleteHeaderDialog.show"
:name="strings.deleteHeaderTitle"
@close="deleteHeaderDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.headerCategoryWarning(deleteHeaderDialog.categoryCount) }}</span>
</div>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithCategories }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="migrate"
type="radio"
name="delete-header-action"
>
{{ strings.migrateCategories }}
</NcCheckboxRadioSwitch>
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetHeader }}</label>
<NcSelect
v-model="selectedTargetHeader"
:options="targetHeaderOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="delete"
type="radio"
name="delete-header-action"
>
{{ strings.deleteCategories }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteHeaderDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="
deleteHeaderDialog.action === 'migrate' &&
deleteHeaderDialog.categoryCount > 0 &&
!selectedTargetHeader
"
@click="executeDeleteHeader"
>
{{ strings.deleteHeader }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import AppToolbar from '@/components/AppToolbar.vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog'
@@ -370,6 +383,9 @@ import type { CategoryHeader, Category, CatHeader } from '@/types'
export default defineComponent({
name: 'AdminCategoryList',
components: {
PageWrapper,
PageHeader,
AppToolbar,
NcButton,
NcCheckboxRadioSwitch,
NcDialog,
@@ -753,8 +769,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-category-list {
max-width: 1200px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
@@ -774,27 +788,10 @@ export default defineComponent({
justify-content: center;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
h2 {
margin: 0 0 6px 0;
}
.header-actions {
display: flex;
gap: 8px;
}
}
.category-list {
.categories-section {
display: flex;
flex-direction: column;
gap: 32px;
}
.header-row {
@@ -807,6 +804,11 @@ export default defineComponent({
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 12px;
margin-top: 32px;
&:first-child {
margin-top: 0;
}
&:hover {
background: var(--color-background-hover);

View File

@@ -1,135 +1,172 @@
<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 class="contributors-grid">
<!-- Recent contributors (last 7 days) -->
<div class="contributors-column">
<h4>{{ strings.last7Days }}</h4>
<div v-if="stats.topContributorsRecent.length > 0" class="contributors-list">
<div
v-for="(contributor, index) in stats.topContributorsRecent"
: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.threadsCount(contributor.threadCount) }} /
{{ strings.postsCount(contributor.postCount) }}
</div>
</template>
</UserInfo>
</div>
</div>
<div v-else class="muted">{{ strings.noContributors }}</div>
</div>
<!-- All-time contributors -->
<div class="contributors-column">
<h4>{{ strings.allTime }}</h4>
<div v-if="stats.topContributorsAllTime.length > 0" class="contributors-list">
<div
v-for="(contributor, index) in stats.topContributorsAllTime"
: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.threadsCount(contributor.threadCount) }} /
{{ strings.postsCount(contributor.postCount) }}
</div>
</template>
</UserInfo>
</div>
</div>
<div v-else class="muted">{{ strings.noContributors }}</div>
</div>
</div>
</section>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -137,7 +174,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'
@@ -158,9 +197,15 @@ interface DashboardStats {
threads: number
posts: number
}
topContributors: Array<{
topContributorsAllTime: Array<{
userId: string
postCount: number
threadCount: number
}>
topContributorsRecent: Array<{
userId: string
postCount: number
threadCount: number
}>
}
@@ -170,7 +215,9 @@ export default defineComponent({
NcButton,
NcEmptyContent,
NcLoadingIcon,
NcAvatar,
UserInfo,
PageWrapper,
PageHeader,
AccountMultipleIcon,
AccountPlusIcon,
ForumIcon,
@@ -200,6 +247,9 @@ export default defineComponent({
newPosts: t('forum', 'New Posts'),
topContributors: t('forum', 'Top Contributors'),
noContributors: t('forum', 'No contributors yet'),
last7Days: t('forum', 'Last 7 Days'),
allTime: t('forum', 'All Time'),
threadsCount: (count: number) => n('forum', '%n thread', '%n threads', count),
postsCount: (count: number) => n('forum', '%n post', '%n posts', count),
},
}
@@ -317,11 +367,29 @@ export default defineComponent({
}
}
.contributors-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.contributors-column {
h4 {
margin: 0 0 12px 0;
font-size: 1rem;
font-weight: 500;
color: var(--color-text-maxcontrast);
}
}
.contributors-list {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 600px;
}
.contributor-item {
@@ -346,18 +414,9 @@ export default defineComponent({
font-size: 0.9rem;
}
.contributor-info {
flex: 1;
.contributor-name {
font-weight: 500;
color: var(--color-main-text);
}
.contributor-stats {
font-size: 0.85rem;
margin-top: 2px;
}
.contributor-stats {
font-size: 0.85rem;
margin-top: 2px;
}
}
}

View File

@@ -1,79 +1,78 @@
<template>
<div class="admin-general-settings">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<PageWrapper>
<div class="admin-general-settings">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="loadSettings">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="loadSettings">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Settings form -->
<div v-else class="settings-form">
<div class="form-section">
<h3>{{ strings.appearanceTitle }}</h3>
<p class="muted">{{ strings.appearanceDesc }}</p>
<!-- Settings form -->
<div v-else class="settings-form">
<div class="form-section">
<h3>{{ strings.appearanceTitle }}</h3>
<p class="muted">{{ strings.appearanceDesc }}</p>
<div class="form-group">
<label for="forum-title">{{ strings.forumTitle }}</label>
<NcTextField
id="forum-title"
v-model.trim="formData.title"
:placeholder="strings.forumTitlePlaceholder"
:maxlength="100"
/>
<p class="hint">{{ strings.forumTitleHint }}</p>
<div class="form-group">
<label for="forum-title">{{ strings.forumTitle }}</label>
<NcTextField
id="forum-title"
v-model.trim="formData.title"
:placeholder="strings.forumTitlePlaceholder"
:maxlength="100"
/>
<p class="hint">{{ strings.forumTitleHint }}</p>
</div>
<div class="form-group">
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
<NcTextArea
id="forum-subtitle"
v-model.trim="formData.subtitle"
:placeholder="strings.forumSubtitlePlaceholder"
:rows="3"
:maxlength="500"
/>
<p class="hint">{{ strings.forumSubtitleHint }}</p>
</div>
</div>
<div class="form-group">
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
<NcTextArea
id="forum-subtitle"
v-model.trim="formData.subtitle"
:placeholder="strings.forumSubtitlePlaceholder"
:rows="3"
:maxlength="500"
/>
<p class="hint">{{ strings.forumSubtitleHint }}</p>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="saving || !hasChanges" @click="saveSettings">
<template #icon>
<NcLoadingIcon v-if="saving" :size="20" />
<CheckIcon v-else :size="20" />
</template>
{{ strings.save }}
</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;
@@ -269,6 +271,7 @@ export default defineComponent({
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
align-items: center;
}

View File

@@ -1,175 +1,225 @@
<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"
: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="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
/>
</div>
</div>
</section>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canView"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<!-- Colors Section -->
<section class="form-section">
<h3>{{ strings.colors }}</h3>
<p class="muted">{{ strings.colorsDesc }}</p>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canModerate"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
<div class="colors-grid">
<div class="color-group">
<label>{{ strings.colorLight }}</label>
<div class="color-picker-row">
<NcColorPicker v-model="formData.colorLight" @update:value="onLightColorChange">
<NcButton>
<template #icon>
<div
class="color-preview"
:style="{ backgroundColor: formData.colorLight }"
/>
</template>
{{ formData.colorLight || strings.colorLightPlaceholder }}
</NcButton>
</NcColorPicker>
</div>
</div>
</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 class="color-group">
<label>{{ strings.colorDark }}</label>
<div class="color-picker-row">
<NcColorPicker v-model="formData.colorDark" @update:value="onDarkColorChange">
<NcButton>
<template #icon>
<div class="color-preview" :style="{ backgroundColor: formData.colorDark }" />
</template>
{{ formData.colorDark || strings.colorDarkPlaceholder }}
</NcButton>
</NcColorPicker>
<NcButton @click="resetDarkColor">
{{ strings.reset }}
</NcButton>
</div>
</div>
</div>
</section>
<!-- Role Permissions Section -->
<section class="form-section">
<h3>{{ strings.rolePermissions }}</h3>
<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">
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
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'
@@ -184,12 +234,16 @@ export default defineComponent({
components: {
NcButton,
NcCheckboxRadioSwitch,
NcColorPicker,
NcEmptyContent,
NcLoadingIcon,
NcTextField,
NcTextArea,
PageHeader,
ArrowLeftIcon,
InformationIcon,
PageWrapper,
AppToolbar,
},
data() {
return {
@@ -200,10 +254,13 @@ export default defineComponent({
formData: {
name: '',
description: '',
colorLight: '#000000',
colorDark: '#ffffff',
canAccessAdminTools: false,
canEditRoles: false,
canEditCategories: false,
},
darkColorModified: false,
permissions: {} as Record<number, CategoryPermission>,
strings: {
@@ -220,6 +277,13 @@ export default defineComponent({
namePlaceholder: t('forum', 'Enter role name'),
descriptionPlaceholder: t('forum', 'Enter role description (optional)'),
systemRoleNameWarning: t('forum', 'System role names cannot be changed'),
colors: t('forum', 'Colors'),
colorsDesc: t('forum', 'Set colors for this role badge'),
colorLight: t('forum', 'Light Mode Color'),
colorDark: t('forum', 'Dark Mode Color'),
colorLightPlaceholder: t('forum', '#000000'),
colorDarkPlaceholder: t('forum', '#ffffff'),
reset: t('forum', 'Reset'),
rolePermissions: t('forum', 'Role Permissions'),
rolePermissionsDesc: t('forum', 'Set global permissions for this role'),
canAccessAdminTools: t('forum', 'Can Access Admin Tools'),
@@ -328,10 +392,17 @@ export default defineComponent({
this.formData.name = role.name
this.formData.description = role.description || ''
this.formData.colorLight = role.colorLight || '#000000'
this.formData.colorDark = role.colorDark || '#ffffff'
this.formData.canAccessAdminTools = role.canAccessAdminTools || false
this.formData.canEditRoles = role.canEditRoles || false
this.formData.canEditCategories = role.canEditCategories || false
// If colors are different, mark dark as modified
if (role.colorLight && role.colorDark && role.colorLight !== role.colorDark) {
this.darkColorModified = true
}
// Load role permissions
const permsResponse = await ocs.get<
Array<{
@@ -378,6 +449,8 @@ export default defineComponent({
const roleData = {
name: this.formData.name.trim(),
description: this.formData.description.trim() || null,
colorLight: this.formData.colorLight || null,
colorDark: this.formData.colorDark || null,
canAccessAdminTools: this.formData.canAccessAdminTools,
canEditRoles: this.formData.canEditRoles,
canEditCategories: this.formData.canEditCategories,
@@ -423,14 +496,30 @@ export default defineComponent({
goBack(): void {
this.$router.push('/admin/roles')
},
onLightColorChange(): void {
// If dark color hasn't been manually modified, update it too
if (!this.darkColorModified) {
this.formData.colorDark = this.formData.colorLight
}
},
onDarkColorChange(): void {
// Mark dark color as manually modified
this.darkColorModified = true
},
resetDarkColor(): void {
// Reset dark color to match light color
this.formData.colorDark = this.formData.colorLight
this.darkColorModified = false
},
},
})
</script>
<style scoped lang="scss">
.admin-role-edit {
max-width: 1200px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
@@ -453,10 +542,6 @@ export default defineComponent({
.page-header {
margin-bottom: 24px;
.header-actions {
margin-bottom: 12px;
}
h2 {
margin: 0 0 6px 0;
}
@@ -504,6 +589,39 @@ export default defineComponent({
}
}
.colors-grid {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-top: 12px;
.color-group {
display: flex;
flex-direction: column;
gap: 16px;
flex: 0 1 auto;
label {
font-weight: 600;
color: var(--color-main-text);
font-size: 0.95rem;
}
.color-picker-row {
display: flex;
gap: 8px;
align-items: center;
}
.color-preview {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid var(--color-border);
}
}
}
.permissions-checkboxes {
display: flex;
flex-direction: column;
@@ -605,7 +723,7 @@ export default defineComponent({
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}

View File

@@ -1,104 +1,101 @@
<template>
<div class="admin-role-list">
<div class="page-header">
<div class="header-content">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<NcButton @click="createRole" variant="primary">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
<PageWrapper :full-width="true">
<template #toolbar>
<AppToolbar>
<template #right>
<NcButton @click="createRole" variant="primary">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="admin-role-list">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Role list -->
<AdminTable
v-else-if="roles.length > 0"
:columns="tableColumns"
:rows="roles"
row-key="id"
:has-actions="true"
:actions-label="strings.actions"
>
<template #cell-id="{ row }">
<span class="role-id">{{ row.id }}</span>
</template>
<template #cell-name="{ row }">
<RoleBadge :role="row" />
</template>
<template #cell-description="{ row }">
<span v-if="row.description" class="role-description">{{ row.description }}</span>
<span v-else class="muted">{{ strings.noDescription }}</span>
</template>
<template #cell-created="{ row }">
<NcDateTime :timestamp="row.createdAt * 1000" />
</template>
<template #actions="{ row }">
<NcActions variant="secondary">
<NcActionButton @click="editRole(row.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcActionButton>
<NcActionButton :disabled="isSystemRole(row.id)" @click="confirmDelete(row)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcActionButton>
</NcActions>
</template>
</AdminTable>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createRole">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</template>
</NcEmptyContent>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Role list -->
<div v-else-if="roles.length > 0" class="roles-content">
<div class="roles-table">
<div class="table-header">
<div class="col-id">{{ strings.id }}</div>
<div class="col-name">{{ strings.name }}</div>
<div class="col-description">{{ strings.description }}</div>
<div class="col-created">{{ strings.created }}</div>
<div class="col-actions">{{ strings.actions }}</div>
</div>
<div v-for="role in roles" :key="role.id" class="table-row">
<div class="col-id">
<span class="role-id">{{ role.id }}</span>
</div>
<div class="col-name">
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
</div>
<div class="col-description">
<span v-if="role.description" class="role-description">{{ role.description }}</span>
<span v-else class="muted">{{ strings.noDescription }}</span>
</div>
<div class="col-created">
<NcDateTime :timestamp="role.createdAt * 1000" />
</div>
<div class="col-actions">
<NcActions>
<NcActionButton @click="editRole(role.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcActionButton>
<NcActionButton :disabled="isSystemRole(role.id)" @click="confirmDelete(role)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcActionButton>
</NcActions>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createRole">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</template>
</NcEmptyContent>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -109,9 +106,14 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import AdminTable, { type TableColumn } from '@/components/AdminTable.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import PlusIcon from '@icons/Plus.vue'
import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import AppToolbar from '@/components/AppToolbar.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role } from '@/types'
@@ -125,9 +127,14 @@ export default defineComponent({
NcDateTime,
NcActions,
NcActionButton,
AdminTable,
RoleBadge,
PlusIcon,
PencilIcon,
DeleteIcon,
PageWrapper,
PageHeader,
AppToolbar,
},
data() {
return {
@@ -162,6 +169,16 @@ export default defineComponent({
},
}
},
computed: {
tableColumns(): TableColumn[] {
return [
{ key: 'id', label: this.strings.id, minWidth: '50px', maxWidth: '100px' },
{ key: 'name', label: this.strings.name, minWidth: '120px' },
{ key: 'description', label: this.strings.description, minWidth: '250px' },
{ key: 'created', label: this.strings.created, minWidth: '120px' },
]
},
},
created() {
this.refresh()
},
@@ -186,15 +203,6 @@ export default defineComponent({
return roleId <= 3
},
getRoleClass(roleId: number): string {
const roleClasses: Record<number, string> = {
1: 'role-admin',
2: 'role-moderator',
3: 'role-member',
}
return roleClasses[roleId] || ''
},
createRole(): void {
this.$router.push('/admin/roles/create')
},
@@ -248,96 +256,17 @@ export default defineComponent({
justify-content: center;
}
.page-header {
margin-bottom: 24px;
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
h2 {
margin: 0 0 6px 0;
}
// Custom cell content styling
:deep(.role-id) {
font-weight: 600;
font-family: monospace;
font-size: 0.9rem;
color: var(--color-text-maxcontrast);
}
.roles-content {
.roles-table {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border);
border-radius: 8px;
overflow: hidden;
.table-header,
.table-row {
display: grid;
grid-template-columns: 60px 200px 1fr 150px 80px;
gap: 16px;
padding: 16px;
background: var(--color-main-background);
align-items: center;
}
.table-header {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-maxcontrast);
background: var(--color-background-hover);
}
.table-row {
&:hover {
background: var(--color-background-hover);
}
.col-id {
.role-id {
font-weight: 600;
font-family: monospace;
font-size: 0.9rem;
color: var(--color-text-maxcontrast);
}
}
.col-name {
.role-name {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
&.role-admin {
color: var(--color-error);
}
&.role-moderator {
color: var(--color-warning);
}
&.role-member {
color: var(--color-primary);
}
}
}
.col-description {
.role-description {
color: var(--color-text-lighter);
font-size: 0.9rem;
}
}
.col-actions {
display: flex;
justify-content: flex-end;
}
}
}
:deep(.role-description) {
color: var(--color-text-lighter);
font-size: 0.9rem;
}
}
</style>

View File

@@ -1,158 +1,144 @@
<template>
<div class="admin-user-list">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<PageWrapper :full-width="true">
<div class="admin-user-list">
<PageHeader :title="strings.title" :subtitle="strings.subtitle" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- User list -->
<div v-else-if="users.length > 0" class="users-content">
<div class="users-table">
<div class="table-header">
<div class="col-user">{{ strings.user }}</div>
<div class="col-posts">{{ strings.posts }}</div>
<div class="col-roles">{{ strings.roles }}</div>
<div class="col-joined">{{ strings.joined }}</div>
<div class="col-status">{{ strings.status }}</div>
</div>
<div
v-for="user in users"
:key="user.userId"
class="table-row"
:class="{ 'is-deleted': user.isDeleted }"
>
<div class="col-user">
<NcAvatar :user="user.userId" :size="40" />
<div class="user-info">
<div class="user-name">{{ user.displayName }}</div>
<div class="user-id muted">@{{ user.userId }}</div>
</div>
</div>
<div class="col-posts">
<div class="post-stats">
<div class="stat-item">
<span class="stat-value">{{ user.threadCount }}</span>
<span class="stat-label muted">threads</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ user.postCount }}</span>
<span class="stat-label muted">posts</span>
</div>
</div>
</div>
<div class="col-roles">
<div v-if="editingUserId === user.userId" class="roles-editor">
<NcSelect
v-model="editingRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
:multiple="true"
label="name"
track-by="id"
input-label="name"
class="roles-select"
/>
<div class="edit-actions">
<NcButton @click="cancelEdit" :aria-label="strings.cancel" :title="strings.cancel">
<template #icon>
<CloseIcon :size="20" />
</template>
</NcButton>
<NcButton
variant="primary"
@click="saveRoles(user.userId)"
:aria-label="strings.save"
:title="strings.save"
>
<template #icon>
<CheckIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div v-else class="roles-display">
<div class="roles-list">
<span
v-for="roleId in user.roles"
:key="roleId"
class="role-badge"
:class="getRoleBadgeClass(roleId)"
>
{{ getRoleName(roleId) }}
</span>
<span v-if="user.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
</div>
<NcButton
@click="startEdit(user.userId, user.roles)"
:aria-label="strings.edit"
:title="strings.edit"
>
<template #icon>
<PencilIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div class="col-joined">
<NcDateTime :timestamp="user.createdAt * 1000" />
</div>
<div class="col-status">
<span v-if="user.isDeleted" class="status-badge status-deleted">
{{ strings.deleted }}
</span>
<span v-else class="status-badge status-active">
{{ strings.active }}
</span>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- User list -->
<AdminTable
v-else-if="users.length > 0"
:columns="tableColumns"
:rows="users"
row-key="userId"
:has-actions="true"
:actions-label="strings.actions"
:row-class="(user) => ({ 'is-deleted': user.isDeleted })"
>
<template #cell-user="{ row }">
<UserInfo :user-id="row.userId" :display-name="row.displayName" :avatar-size="40">
<template #meta>
<div class="user-id muted">@{{ row.userId }}</div>
</template>
</UserInfo>
</template>
<template #cell-posts="{ row }">
<div class="post-stats">
<div class="stat-item">
<span class="stat-value">{{ row.threadCount }}</span>
<span class="stat-label muted">threads</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ row.postCount }}</span>
<span class="stat-label muted">posts</span>
</div>
</div>
</template>
<template #cell-roles="{ row }">
<div class="roles-list">
<RoleBadge v-for="role in row.roles" :key="role.id" :role="role" density="compact" />
<span v-if="row.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
</div>
</template>
<template #cell-joined="{ row }">
<NcDateTime :timestamp="row.createdAt * 1000" />
</template>
<template #cell-status="{ row }">
<span v-if="row.isDeleted" class="status-badge status-deleted">
{{ strings.deleted }}
</span>
<span v-else class="status-badge status-active">
{{ strings.active }}
</span>
</template>
<template #actions="{ row }">
<NcActions variant="secondary">
<NcActionButton
@click="startEdit(row.userId, row.roles)"
:aria-label="strings.editRoles"
:title="strings.editRoles"
>
<template #icon>
<PencilIcon :size="20" />
</template>
</NcActionButton>
</NcActions>
</template>
</AdminTable>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
<!-- Edit Roles Dialog -->
<NcDialog v-if="editingUserId !== null" :name="strings.editRolesTitle" @close="cancelEdit">
<div class="edit-roles-dialog">
<NcSelect
v-model="editingRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
:multiple="true"
label="name"
:input-label="strings.selectRoles"
track-by="id"
class="roles-select"
/>
</div>
<template #actions>
<NcButton @click="cancelEdit">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" @click="saveRoles(editingUserId)">
{{ strings.save }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import UserInfo from '@/components/UserInfo.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import AdminTable, { type TableColumn } from '@/components/AdminTable.vue'
import PencilIcon from '@icons/Pencil.vue'
import CheckIcon from '@icons/Check.vue'
import CloseIcon from '@icons/Close.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PageHeader from '@/components/PageHeader.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role } from '@/types'
@@ -166,7 +152,7 @@ interface AdminUser {
updatedAt: number
deletedAt: number | null
isDeleted: boolean
roles: number[]
roles: Role[]
}
interface RoleOption {
@@ -178,14 +164,19 @@ export default defineComponent({
name: 'AdminUserList',
components: {
NcButton,
NcActions,
NcActionButton,
NcEmptyContent,
NcLoadingIcon,
NcAvatar,
NcDateTime,
NcSelect,
NcDialog,
UserInfo,
RoleBadge,
AdminTable,
PageWrapper,
PageHeader,
PencilIcon,
CheckIcon,
CloseIcon,
},
data() {
return {
@@ -210,11 +201,13 @@ export default defineComponent({
roles: t('forum', 'Roles'),
joined: t('forum', 'Joined'),
status: t('forum', 'Status'),
actions: t('forum', 'Actions'),
active: t('forum', 'Active'),
deleted: t('forum', 'Deleted'),
noRoles: t('forum', 'No roles'),
selectRoles: t('forum', 'Select roles'),
edit: t('forum', 'Edit roles'),
editRoles: t('forum', 'Edit roles'),
editRolesTitle: t('forum', 'Edit User Roles'),
save: t('forum', 'Save'),
cancel: t('forum', 'Cancel'),
},
@@ -227,6 +220,15 @@ export default defineComponent({
name: role.name,
}))
},
tableColumns(): TableColumn[] {
return [
{ key: 'user', label: this.strings.user, minWidth: '200px' },
{ key: 'posts', label: this.strings.posts, minWidth: '160px' },
{ key: 'roles', label: this.strings.roles, minWidth: '150px' },
{ key: 'joined', label: this.strings.joined, minWidth: '120px' },
{ key: 'status', label: this.strings.status, minWidth: '80px' },
]
},
},
created() {
this.refresh()
@@ -253,27 +255,14 @@ export default defineComponent({
}
},
getRoleName(roleId: number): string {
const role = this.allRoles.find((r) => r.id === roleId)
return role?.name || t('forum', 'Unknown Role')
},
getRoleBadgeClass(roleId: number): string {
const roleClasses: Record<number, string> = {
1: 'role-admin',
2: 'role-moderator',
3: 'role-member',
}
return roleClasses[roleId] || 'role-unknown'
},
startEdit(userId: string, currentRoles: number[]): void {
startEdit(userId: string, currentRoles: Role[]): void {
this.editingUserId = userId
this.originalRoles = [...currentRoles]
this.originalRoles = currentRoles.map((r) => r.id)
// Convert role IDs to role options for NcSelectTags
// Convert roles to role options for NcSelectTags
// IMPORTANT: Must use the same object references from roleOptions
this.editingRoles = this.roleOptions.filter((option) => currentRoles.includes(option.id))
const currentRoleIds = currentRoles.map((r) => r.id)
this.editingRoles = this.roleOptions.filter((option) => currentRoleIds.includes(option.id))
},
cancelEdit(): void {
@@ -315,7 +304,7 @@ export default defineComponent({
// Update local user data
const user = this.users.find((u) => u.userId === userId)
if (user) {
user.roles = newRoleIds
user.roles = this.allRoles.filter((r) => newRoleIds.includes(r.id))
}
this.cancelEdit()
@@ -349,184 +338,81 @@ export default defineComponent({
justify-content: center;
}
.page-header {
margin-bottom: 24px;
// Row-specific styling
:deep(.is-deleted > div) {
opacity: 0.6;
}
h2 {
margin: 0 0 6px 0;
// Custom cell content styling
.user-id {
font-size: 0.85rem;
}
.post-stats {
display: flex;
align-items: center;
gap: 8px;
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
.stat-value {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.stat-divider {
color: var(--color-text-maxcontrast);
font-weight: 300;
}
}
.users-content {
.users-table {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border);
border-radius: 8px;
overflow: hidden;
.roles-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.table-header,
.table-row {
display: grid;
grid-template-columns: 2fr 100px 2fr 150px 100px;
gap: 16px;
padding: 16px;
background: var(--color-main-background);
align-items: center;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
.table-header {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-maxcontrast);
background: var(--color-background-hover);
}
.table-row {
&:hover {
background: var(--color-background-hover);
}
&.is-deleted {
opacity: 0.6;
}
.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;
}
}
}
.col-posts {
.post-stats {
display: flex;
align-items: center;
gap: 8px;
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
.stat-value {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.stat-divider {
color: var(--color-text-maxcontrast);
font-weight: 300;
}
}
}
.col-roles {
.roles-editor {
display: flex;
align-items: center;
gap: 8px;
.roles-select {
flex: 1;
min-width: 200px;
}
.edit-actions {
display: flex;
gap: 4px;
}
}
.roles-display {
display: flex;
align-items: center;
gap: 8px;
.roles-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
.role-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&.role-admin {
background: var(--color-error-light);
color: var(--color-error-dark);
}
&.role-moderator {
background: var(--color-warning-light);
color: var(--color-warning-dark);
}
&.role-member {
background: var(--color-primary-light);
color: var(--color-primary-dark);
}
&.role-unknown {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
}
}
}
.col-status {
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&.status-active {
background: var(--color-success-light);
color: var(--color-success-dark);
}
&.status-deleted {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
}
}
&.status-active {
background: var(--color-success-light);
color: var(--color-success-dark);
}
&.status-deleted {
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
}
}
.edit-roles-dialog {
padding: 16px 0;
width: 100%;
}
}
</style>
<style lang="scss">
.edit-roles-dialog {
.roles-select {
width: 100%;
}
}
</style>

View File

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

Some files were not shown because too many files have changed in this diff Show More