mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: add dashboard widgets
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
8
css/dashboard.css
Normal 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
4
img/folder-dark.svg
Normal 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
5
img/thread-dark.svg
Normal 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 |
@@ -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 {
|
||||
|
||||
101
lib/Dashboard/RecentActivityWidget.php
Normal file
101
lib/Dashboard/RecentActivityWidget.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/Dashboard/TopActivityWidget.php
Normal file
108
lib/Dashboard/TopActivityWidget.php
Normal 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')
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/Dashboard/TopCategoriesWidget.php
Normal file
87
lib/Dashboard/TopCategoriesWidget.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\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')
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/Dashboard/TopThreadsWidget.php
Normal file
87
lib/Dashboard/TopThreadsWidget.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\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')
|
||||
);
|
||||
}
|
||||
}
|
||||
180
lib/Dashboard/WidgetService.php
Normal file
180
lib/Dashboard/WidgetService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user