From b6cc80d1f80a148074569d5124d17891a3bdc1dd Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Wed, 28 Jan 2026 02:12:35 +0200 Subject: [PATCH] feat: add dashboard widgets --- .gitignore | 3 +- css/dashboard.css | 8 ++ img/folder-dark.svg | 4 + img/thread-dark.svg | 5 + lib/AppInfo/Application.php | 10 ++ lib/Dashboard/RecentActivityWidget.php | 101 ++++++++++++++ lib/Dashboard/TopActivityWidget.php | 108 +++++++++++++++ lib/Dashboard/TopCategoriesWidget.php | 87 ++++++++++++ lib/Dashboard/TopThreadsWidget.php | 87 ++++++++++++ lib/Dashboard/WidgetService.php | 180 +++++++++++++++++++++++++ lib/Db/CategoryMapper.php | 22 +++ lib/Db/PostMapper.php | 27 ++++ lib/Db/ThreadMapper.php | 48 +++++++ 13 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 css/dashboard.css create mode 100644 img/folder-dark.svg create mode 100644 img/thread-dark.svg create mode 100644 lib/Dashboard/RecentActivityWidget.php create mode 100644 lib/Dashboard/TopActivityWidget.php create mode 100644 lib/Dashboard/TopCategoriesWidget.php create mode 100644 lib/Dashboard/TopThreadsWidget.php create mode 100644 lib/Dashboard/WidgetService.php diff --git a/.gitignore b/.gitignore index 837d6d2..0e5062c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,8 @@ /node_modules/ /dist /js -/css +/css/* +!/css/dashboard.css .DS_Store build/ tsconfig.app.tsbuildinfo diff --git a/css/dashboard.css b/css/dashboard.css new file mode 100644 index 0000000..d96fe94 --- /dev/null +++ b/css/dashboard.css @@ -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); +} diff --git a/img/folder-dark.svg b/img/folder-dark.svg new file mode 100644 index 0000000..c5498de --- /dev/null +++ b/img/folder-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/img/thread-dark.svg b/img/thread-dark.svg new file mode 100644 index 0000000..26f0053 --- /dev/null +++ b/img/thread-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 9a9f78c..ffd5524 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -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 { diff --git a/lib/Dashboard/RecentActivityWidget.php b/lib/Dashboard/RecentActivityWidget.php new file mode 100644 index 0000000..fc7f246 --- /dev/null +++ b/lib/Dashboard/RecentActivityWidget.php @@ -0,0 +1,101 @@ + +// 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') + ); + } +} diff --git a/lib/Dashboard/TopActivityWidget.php b/lib/Dashboard/TopActivityWidget.php new file mode 100644 index 0000000..ec84eee --- /dev/null +++ b/lib/Dashboard/TopActivityWidget.php @@ -0,0 +1,108 @@ + +// 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') + ); + } +} diff --git a/lib/Dashboard/TopCategoriesWidget.php b/lib/Dashboard/TopCategoriesWidget.php new file mode 100644 index 0000000..c2fab69 --- /dev/null +++ b/lib/Dashboard/TopCategoriesWidget.php @@ -0,0 +1,87 @@ + +// 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') + ); + } +} diff --git a/lib/Dashboard/TopThreadsWidget.php b/lib/Dashboard/TopThreadsWidget.php new file mode 100644 index 0000000..6a0b345 --- /dev/null +++ b/lib/Dashboard/TopThreadsWidget.php @@ -0,0 +1,87 @@ + +// 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') + ); + } +} diff --git a/lib/Dashboard/WidgetService.php b/lib/Dashboard/WidgetService.php new file mode 100644 index 0000000..cf455dc --- /dev/null +++ b/lib/Dashboard/WidgetService.php @@ -0,0 +1,180 @@ + +// 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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'); + } +} diff --git a/lib/Db/CategoryMapper.php b/lib/Db/CategoryMapper.php index 748c707..682f9f4 100644 --- a/lib/Db/CategoryMapper.php +++ b/lib/Db/CategoryMapper.php @@ -212,6 +212,28 @@ class CategoryMapper extends QBMapper { return (int)($row['count'] ?? 0); } + /** + * Find top categories by thread count + * + * @param array $categoryIds Category IDs to filter by (already permission-filtered) + * @param int $limit Maximum results + * @return array + */ + 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 * diff --git a/lib/Db/PostMapper.php b/lib/Db/PostMapper.php index 0466919..4b1ed6d 100644 --- a/lib/Db/PostMapper.php +++ b/lib/Db/PostMapper.php @@ -336,6 +336,33 @@ class PostMapper extends QBMapper { return (int)($row['position'] ?? 0); } + /** + * Find recent replies (non-first posts) in specified categories + * + * @param array $categoryIds Category IDs to filter by + * @param int $limit Maximum results + * @return array + */ + 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) * diff --git a/lib/Db/ThreadMapper.php b/lib/Db/ThreadMapper.php index 6995537..2b8ede7 100644 --- a/lib/Db/ThreadMapper.php +++ b/lib/Db/ThreadMapper.php @@ -220,6 +220,54 @@ class ThreadMapper extends QBMapper { return $this->findEntities($qb); } + /** + * Find recent threads in specified categories + * + * @param array $categoryIds Category IDs to filter by + * @param int $limit Maximum results + * @return array + */ + 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 $categoryIds Category IDs to filter by + * @param int $limit Maximum results + * @return array + */ + 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 *