feat: add dashboard widgets

This commit is contained in:
2026-01-28 02:12:35 +02:00
parent 88f4062d81
commit b6cc80d1f8
13 changed files with 689 additions and 1 deletions

3
.gitignore vendored
View File

@@ -10,7 +10,8 @@
/node_modules/
/dist
/js
/css
/css/*
!/css/dashboard.css
.DS_Store
build/
tsconfig.app.tsbuildinfo

8
css/dashboard.css Normal file
View File

@@ -0,0 +1,8 @@
/**
* Dashboard widget icon theming
* Inverts dark icons in dark mode using Nextcloud's CSS variable
*/
img[src*="/forum/img/thread-dark.svg"],
img[src*="/forum/img/folder-dark.svg"] {
filter: var(--background-invert-if-dark);
}

4
img/folder-dark.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 238 B

5
img/thread-dark.svg Normal file
View File

@@ -0,0 +1,5 @@
<?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="thread" 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>

After

Width:  |  Height:  |  Size: 696 B

View File

@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace OCA\Forum\AppInfo;
use OCA\Forum\Dashboard\RecentActivityWidget;
use OCA\Forum\Dashboard\TopActivityWidget;
use OCA\Forum\Dashboard\TopCategoriesWidget;
use OCA\Forum\Dashboard\TopThreadsWidget;
use OCA\Forum\Listener\UserEventListener;
use OCA\Forum\Middleware\PermissionMiddleware;
use OCA\Forum\Notification\Notifier;
@@ -37,6 +41,12 @@ class Application extends App implements IBootstrap {
// Register notification notifier
$context->registerNotifierService(Notifier::class);
// Register dashboard widgets
$context->registerDashboardWidget(RecentActivityWidget::class);
$context->registerDashboardWidget(TopActivityWidget::class);
$context->registerDashboardWidget(TopCategoriesWidget::class);
$context->registerDashboardWidget(TopThreadsWidget::class);
}
public function boot(IBootContext $context): void {

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Dashboard;
use OCA\Forum\AppInfo\Application;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Dashboard\IButtonWidget;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\Model\WidgetButton;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetItems;
use OCP\IL10N;
use OCP\IURLGenerator;
class RecentActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
public function __construct(
private IL10N $l,
private IURLGenerator $urlGenerator,
private WidgetService $widgetService,
) {
}
public function getId(): string {
return 'forum-recent-activity';
}
public function getTitle(): string {
return $this->l->t('Recent Forum activity');
}
public function getOrder(): int {
return 10;
}
public function getIconClass(): string {
return 'icon-forum';
}
public function getIconUrl(): string {
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
}
public function getUrl(): ?string {
return $this->widgetService->getForumUrl();
}
public function load(): void {
\OCP\Util::addStyle(Application::APP_ID, 'dashboard');
}
public function getWidgetButtons(string $userId): array {
return [
new WidgetButton(
WidgetButton::TYPE_MORE,
$this->widgetService->getForumUrl(),
$this->l->t('More activity')
),
];
}
public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
$activity = $this->widgetService->getRecentActivity($userId, $limit);
$items = [];
foreach ($activity as $entry) {
$thread = $entry['thread'];
if ($thread === null) {
continue;
}
$title = $thread->getTitle();
$link = $this->widgetService->getThreadUrl($thread);
$sinceId = (string)$entry['createdAt'];
if ($entry['type'] === 'thread') {
$subtitle = $this->l->t('New thread by %1$s', [$thread->getAuthorId()]);
} else {
$post = $entry['item'];
$subtitle = $this->l->t('Reply by %1$s', [$post->getAuthorId()]);
}
$items[] = new WidgetItem(
$title,
$subtitle,
$link,
$this->widgetService->getThreadIconUrl(),
$sinceId
);
}
return new WidgetItems(
$items,
$this->l->t('No recent forum activity')
);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Dashboard;
use OCA\Forum\AppInfo\Application;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Dashboard\IButtonWidget;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\Model\WidgetButton;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetItems;
use OCP\IL10N;
use OCP\IURLGenerator;
class TopActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
public function __construct(
private IL10N $l,
private IURLGenerator $urlGenerator,
private WidgetService $widgetService,
) {
}
public function getId(): string {
return 'forum-top-activity';
}
public function getTitle(): string {
return $this->l->t('Top Forum activity');
}
public function getOrder(): int {
return 13;
}
public function getIconClass(): string {
return 'icon-forum';
}
public function getIconUrl(): string {
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
}
public function getUrl(): ?string {
return $this->widgetService->getForumUrl();
}
public function load(): void {
\OCP\Util::addStyle(Application::APP_ID, 'dashboard');
}
public function getWidgetButtons(string $userId): array {
return [
new WidgetButton(
WidgetButton::TYPE_MORE,
$this->widgetService->getForumUrl(),
$this->l->t('Browse forum')
),
];
}
public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
$items = [];
// Get top categories (half of limit, rounded up)
$categoryLimit = (int)ceil($limit / 2);
$categories = $this->widgetService->getTopCategories($userId, $categoryLimit);
foreach ($categories as $category) {
$threadCount = $category->getThreadCount();
$items[] = new WidgetItem(
$category->getName(),
$this->l->n('%n thread', '%n threads', $threadCount),
$this->widgetService->getCategoryUrl($category),
$this->widgetService->getCategoryIconUrl(),
'cat-' . $category->getId()
);
}
// Get top threads (remaining slots)
$threadLimit = $limit - count($items);
if ($threadLimit > 0) {
$threads = $this->widgetService->getTopThreads($userId, $threadLimit);
foreach ($threads as $thread) {
$viewCount = $thread->getViewCount();
$items[] = new WidgetItem(
$thread->getTitle(),
$this->l->n('%n view', '%n views', $viewCount),
$this->widgetService->getThreadUrl($thread),
$this->widgetService->getThreadIconUrl(),
'thread-' . $thread->getId()
);
}
}
return new WidgetItems(
$items,
$this->l->t('No forum activity')
);
}
}

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\Dashboard;
use OCA\Forum\AppInfo\Application;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Dashboard\IButtonWidget;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\Model\WidgetButton;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetItems;
use OCP\IL10N;
use OCP\IURLGenerator;
class TopCategoriesWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
public function __construct(
private IL10N $l,
private IURLGenerator $urlGenerator,
private WidgetService $widgetService,
) {
}
public function getId(): string {
return 'forum-top-categories';
}
public function getTitle(): string {
return $this->l->t('Top Forum categories');
}
public function getOrder(): int {
return 11;
}
public function getIconClass(): string {
return 'icon-forum';
}
public function getIconUrl(): string {
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
}
public function getUrl(): ?string {
return $this->widgetService->getForumUrl();
}
public function load(): void {
\OCP\Util::addStyle(Application::APP_ID, 'dashboard');
}
public function getWidgetButtons(string $userId): array {
return [
new WidgetButton(
WidgetButton::TYPE_MORE,
$this->widgetService->getForumUrl(),
$this->l->t('Browse forum')
),
];
}
public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
$categories = $this->widgetService->getTopCategories($userId, $limit);
$items = [];
foreach ($categories as $category) {
$threadCount = $category->getThreadCount();
$items[] = new WidgetItem(
$category->getName(),
$this->l->n('%n thread', '%n threads', $threadCount),
$this->widgetService->getCategoryUrl($category),
$this->widgetService->getCategoryIconUrl(),
(string)$category->getId()
);
}
return new WidgetItems(
$items,
$this->l->t('No categories available')
);
}
}

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\Dashboard;
use OCA\Forum\AppInfo\Application;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Dashboard\IButtonWidget;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\Model\WidgetButton;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetItems;
use OCP\IL10N;
use OCP\IURLGenerator;
class TopThreadsWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
public function __construct(
private IL10N $l,
private IURLGenerator $urlGenerator,
private WidgetService $widgetService,
) {
}
public function getId(): string {
return 'forum-top-threads';
}
public function getTitle(): string {
return $this->l->t('Top Forum threads');
}
public function getOrder(): int {
return 12;
}
public function getIconClass(): string {
return 'icon-forum';
}
public function getIconUrl(): string {
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
}
public function getUrl(): ?string {
return $this->widgetService->getForumUrl();
}
public function load(): void {
\OCP\Util::addStyle(Application::APP_ID, 'dashboard');
}
public function getWidgetButtons(string $userId): array {
return [
new WidgetButton(
WidgetButton::TYPE_MORE,
$this->widgetService->getForumUrl(),
$this->l->t('Browse forum')
),
];
}
public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
$threads = $this->widgetService->getTopThreads($userId, $limit);
$items = [];
foreach ($threads as $thread) {
$viewCount = $thread->getViewCount();
$items[] = new WidgetItem(
$thread->getTitle(),
$this->l->n('%n view', '%n views', $viewCount),
$this->widgetService->getThreadUrl($thread),
$this->widgetService->getThreadIconUrl(),
(string)$thread->getId()
);
}
return new WidgetItems(
$items,
$this->l->t('No threads available')
);
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Dashboard;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Db\Category;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\Thread;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Service\PermissionService;
use OCP\IURLGenerator;
class WidgetService {
public function __construct(
private PermissionService $permissionService,
private ThreadMapper $threadMapper,
private PostMapper $postMapper,
private CategoryMapper $categoryMapper,
private IURLGenerator $urlGenerator,
) {
}
/**
* Get category IDs accessible by the user
*
* @param string $userId
* @return array<int>
*/
public function getAccessibleCategoryIds(string $userId): array {
return $this->permissionService->getAccessibleCategories($userId);
}
/**
* Get recent activity (threads and replies combined)
*
* @param string $userId
* @param int $limit
* @return array<array{type: string, item: Thread|Post, thread: Thread|null, createdAt: int}>
*/
public function getRecentActivity(string $userId, int $limit = 7): array {
$categoryIds = $this->getAccessibleCategoryIds($userId);
if (empty($categoryIds)) {
return [];
}
// Get recent threads and replies
$threads = $this->threadMapper->findRecentThreads($categoryIds, $limit);
$replies = $this->postMapper->findRecentReplies($categoryIds, $limit);
// Combine and sort by created_at
$activity = [];
foreach ($threads as $thread) {
$activity[] = [
'type' => 'thread',
'item' => $thread,
'thread' => $thread,
'createdAt' => $thread->getCreatedAt(),
];
}
// Get thread info for replies
$threadIds = array_unique(array_map(fn ($post) => $post->getThreadId(), $replies));
$threadMap = [];
if (!empty($threadIds)) {
$threadEntities = $this->threadMapper->findByIds($threadIds);
foreach ($threadEntities as $thread) {
$threadMap[$thread->getId()] = $thread;
}
}
foreach ($replies as $post) {
$thread = $threadMap[$post->getThreadId()] ?? null;
if ($thread !== null) {
$activity[] = [
'type' => 'reply',
'item' => $post,
'thread' => $thread,
'createdAt' => $post->getCreatedAt(),
];
}
}
// Sort by createdAt descending
usort($activity, fn ($a, $b) => $b['createdAt'] <=> $a['createdAt']);
return array_slice($activity, 0, $limit);
}
/**
* Get top categories by thread count
*
* @param string $userId
* @param int $limit
* @return array<Category>
*/
public function getTopCategories(string $userId, int $limit = 7): array {
$categoryIds = $this->getAccessibleCategoryIds($userId);
if (empty($categoryIds)) {
return [];
}
return $this->categoryMapper->findTopByThreadCount($categoryIds, $limit);
}
/**
* Get top threads by view count
*
* @param string $userId
* @param int $limit
* @return array<Thread>
*/
public function getTopThreads(string $userId, int $limit = 7): array {
$categoryIds = $this->getAccessibleCategoryIds($userId);
if (empty($categoryIds)) {
return [];
}
return $this->threadMapper->findTopByViews($categoryIds, $limit);
}
/**
* Get URL for forum home
*/
public function getForumUrl(): string {
return $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.page.index');
}
/**
* Get URL for a thread
*/
public function getThreadUrl(Thread $thread): string {
return $this->urlGenerator->linkToRouteAbsolute(
Application::APP_ID . '.page.catchAll',
['path' => 't/' . $thread->getSlug()]
);
}
/**
* Get URL for a category
*/
public function getCategoryUrl(Category $category): string {
return $this->urlGenerator->linkToRouteAbsolute(
Application::APP_ID . '.page.catchAll',
['path' => 'c/' . $category->getSlug()]
);
}
/**
* Get the forum app icon URL (dark/black icon for widget header - auto-inverted by Nextcloud)
*/
public function getIconUrl(): string {
return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
}
/**
* Get the thread icon URL for widget items (black icon, inverted by CSS in dark mode)
*
* @return string
*/
public function getThreadIconUrl(): string {
return $this->urlGenerator->imagePath(Application::APP_ID, 'thread-dark.svg');
}
/**
* Get the folder icon URL for category items (black icon, inverted by CSS in dark mode)
*
* @return string
*/
public function getCategoryIconUrl(): string {
return $this->urlGenerator->imagePath(Application::APP_ID, 'folder-dark.svg');
}
}

View File

@@ -212,6 +212,28 @@ class CategoryMapper extends QBMapper {
return (int)($row['count'] ?? 0);
}
/**
* Find top categories by thread count
*
* @param array<int> $categoryIds Category IDs to filter by (already permission-filtered)
* @param int $limit Maximum results
* @return array<Category>
*/
public function findTopByThreadCount(array $categoryIds, int $limit = 7): array {
if (empty($categoryIds)) {
return [];
}
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->in('id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)))
->orderBy('thread_count', 'DESC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Move all categories from one header to another
*

View File

@@ -336,6 +336,33 @@ class PostMapper extends QBMapper {
return (int)($row['position'] ?? 0);
}
/**
* Find recent replies (non-first posts) in specified categories
*
* @param array<int> $categoryIds Category IDs to filter by
* @param int $limit Maximum results
* @return array<Post>
*/
public function findRecentReplies(array $categoryIds, int $limit = 7): array {
if (empty($categoryIds)) {
return [];
}
$qb = $this->db->getQueryBuilder();
$qb->select('p.*')
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where($qb->expr()->in('t.category_id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->isNull('p.deleted_at'))
->andWhere($qb->expr()->isNull('t.deleted_at'))
->andWhere($qb->expr()->eq('t.is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->orderBy('p.created_at', 'DESC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Search posts by content (replies only, excluding first posts)
*

View File

@@ -220,6 +220,54 @@ class ThreadMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Find recent threads in specified categories
*
* @param array<int> $categoryIds Category IDs to filter by
* @param int $limit Maximum results
* @return array<Thread>
*/
public function findRecentThreads(array $categoryIds, int $limit = 7): array {
if (empty($categoryIds)) {
return [];
}
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->in('category_id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->isNull('deleted_at'))
->orderBy('created_at', 'DESC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Find top threads by view count in specified categories
*
* @param array<int> $categoryIds Category IDs to filter by
* @param int $limit Maximum results
* @return array<Thread>
*/
public function findTopByViews(array $categoryIds, int $limit = 7): array {
if (empty($categoryIds)) {
return [];
}
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->in('category_id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->isNull('deleted_at'))
->orderBy('view_count', 'DESC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Search threads by title and first post content
*