diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml index 399cbd9afee..e23256e7b78 100644 --- a/com.woltlab.wcf/objectType.xml +++ b/com.woltlab.wcf/objectType.xml @@ -979,6 +979,11 @@ com.woltlab.wcf.box.articleList.condition wcf\system\condition\article\ArticleCategoryCondition + + com.woltlab.wcf.articleFeatured + com.woltlab.wcf.box.articleList.condition + wcf\system\condition\article\ArticleFeaturedCondition + diff --git a/com.woltlab.wcf/templates/articleContentHeaderTitle.tpl b/com.woltlab.wcf/templates/articleContentHeaderTitle.tpl index bd949329562..82fa8f7899b 100644 --- a/com.woltlab.wcf/templates/articleContentHeaderTitle.tpl +++ b/com.woltlab.wcf/templates/articleContentHeaderTitle.tpl @@ -58,6 +58,9 @@
  • {icon name='flag'} {content} + {if $article->isFeatured} + {lang}wcf.article.featured{/lang} + {/if} {if $article->isDeleted} {lang}wcf.message.status.deleted{/lang} {/if} diff --git a/com.woltlab.wcf/templates/articleListItems.tpl b/com.woltlab.wcf/templates/articleListItems.tpl index d15a724a9e0..4771e8747f5 100644 --- a/com.woltlab.wcf/templates/articleListItems.tpl +++ b/com.woltlab.wcf/templates/articleListItems.tpl @@ -30,6 +30,7 @@ {hascontent}
    {content} + {if $article->isFeatured}{lang}wcf.article.featured{/lang}{/if} {if $article->isDeleted}{lang}wcf.message.status.deleted{/lang}{/if} {if !$article->isPublished()}{lang}wcf.message.status.disabled{/lang}{/if} diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index b36489ecd63..382dd6c9bd1 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -33,7 +33,8 @@ SmallintDatabaseTableColumn::create('attachments') ->notNull() ->defaultValue(0) - ->drop() + ->drop(), + DefaultFalseBooleanDatabaseTableColumn::create('isFeatured'), ]), PartialDatabaseTable::create('wcf1_article_content') ->columns([ diff --git a/wcfsetup/install/files/lib/command/article/SetArticleAsFeatured.class.php b/wcfsetup/install/files/lib/command/article/SetArticleAsFeatured.class.php new file mode 100644 index 00000000000..9ceddecdf50 --- /dev/null +++ b/wcfsetup/install/files/lib/command/article/SetArticleAsFeatured.class.php @@ -0,0 +1,31 @@ + + * @since 6.3 + */ +final class SetArticleAsFeatured +{ + public function __construct(private readonly Article $article) {} + + public function __invoke(): void + { + ArticleBuilder::forUpdate($this->article) + ->setIsFeatured(true) + ->update(); + + $event = new ArticleSetAsFeatured($this->article); + EventHandler::getInstance()->fire($event); + } +} diff --git a/wcfsetup/install/files/lib/command/article/UnsetArticleAsFeatured.class.php b/wcfsetup/install/files/lib/command/article/UnsetArticleAsFeatured.class.php new file mode 100644 index 00000000000..e3f536b0f56 --- /dev/null +++ b/wcfsetup/install/files/lib/command/article/UnsetArticleAsFeatured.class.php @@ -0,0 +1,31 @@ + + * @since 6.3 + */ +final class UnsetArticleAsFeatured +{ + public function __construct(private readonly Article $article) {} + + public function __invoke(): void + { + ArticleBuilder::forUpdate($this->article) + ->setIsFeatured(false) + ->update(); + + $event = new ArticleUnsetAsFeatured($this->article); + EventHandler::getInstance()->fire($event); + } +} diff --git a/wcfsetup/install/files/lib/data/article/Article.class.php b/wcfsetup/install/files/lib/data/article/Article.class.php index 28979c95e00..7316ba3f7eb 100644 --- a/wcfsetup/install/files/lib/data/article/Article.class.php +++ b/wcfsetup/install/files/lib/data/article/Article.class.php @@ -42,6 +42,7 @@ * @property-read int $cumulativeLikes cumulative result of likes for the article * @property-read 0|1 $isDeleted is 1 if the article is in trash bin, otherwise 0 * @property-read 0|1 $hasLabels is `1` if labels are assigned to the article + * @property-read 0|1 $isFeatured is `1` if the article is featured which pins it at the top, otherwise `0` * * @extends CollectionDatabaseObject */ @@ -173,6 +174,16 @@ public function canPublish(): bool return false; } + /** + * Returns true if the active user can flag articles as featured. + * + * @since 6.3 + */ + public static function canFeature(): bool + { + return WCF::getSession()->hasPermission('admin.content.article.canManageArticle'); + } + /** * Returns true if the active user can create articles in at least one category. * diff --git a/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php b/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php index ce49761bb4c..de2bd898885 100644 --- a/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php +++ b/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php @@ -96,6 +96,13 @@ public function setEnableComments(bool $enableComments): static return $this; } + public function setIsFeatured(bool $isFeatured): static + { + $this->properties['isFeatured'] = $isFeatured ? 1 : 0; + + return $this; + } + public function setIsDeleted(bool $isDeleted): static { $this->properties['isDeleted'] = $isDeleted ? 1 : 0; diff --git a/wcfsetup/install/files/lib/event/article/ArticleSetAsFeatured.class.php b/wcfsetup/install/files/lib/event/article/ArticleSetAsFeatured.class.php new file mode 100644 index 00000000000..8a4faea6dd0 --- /dev/null +++ b/wcfsetup/install/files/lib/event/article/ArticleSetAsFeatured.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class ArticleSetAsFeatured implements IPsr14Event +{ + public function __construct(public readonly Article $article) + { + } +} diff --git a/wcfsetup/install/files/lib/event/article/ArticleUnsetAsFeatured.class.php b/wcfsetup/install/files/lib/event/article/ArticleUnsetAsFeatured.class.php new file mode 100644 index 00000000000..47be12ca2d5 --- /dev/null +++ b/wcfsetup/install/files/lib/event/article/ArticleUnsetAsFeatured.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class ArticleUnsetAsFeatured implements IPsr14Event +{ + public function __construct(public readonly Article $article) + { + } +} diff --git a/wcfsetup/install/files/lib/system/box/ArticleListBoxController.class.php b/wcfsetup/install/files/lib/system/box/ArticleListBoxController.class.php index b8d6b3a348f..32f2c1ecf45 100644 --- a/wcfsetup/install/files/lib/system/box/ArticleListBoxController.class.php +++ b/wcfsetup/install/files/lib/system/box/ArticleListBoxController.class.php @@ -85,7 +85,10 @@ protected function getObjectList(): AccessibleArticleList #[\Override] protected function createListView(): ArticleListView { - return new ArticleListView(); + $listView = new ArticleListView(); + $listView->setPinFeaturedArticles(false); + + return $listView; } #[\Override] diff --git a/wcfsetup/install/files/lib/system/condition/article/ArticleFeaturedCondition.class.php b/wcfsetup/install/files/lib/system/condition/article/ArticleFeaturedCondition.class.php new file mode 100644 index 00000000000..fd180ee75e8 --- /dev/null +++ b/wcfsetup/install/files/lib/system/condition/article/ArticleFeaturedCondition.class.php @@ -0,0 +1,37 @@ + + * @since 6.3 + * + * @implements IObjectListCondition + */ +class ArticleFeaturedCondition extends AbstractCheckboxCondition implements IObjectListCondition +{ + /** + * @inheritDoc + */ + protected $fieldName = 'articleIsFeatured'; + + /** + * @inheritDoc + */ + protected $label = 'wcf.article.condition.isFeatured'; + + #[\Override] + public function addObjectListCondition(DatabaseObjectList $objectList, array $conditionData): void + { + $objectList->getConditionBuilder()->add('article.isFeatured = ?', [1]); + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/SetArticleAsFeatured.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/SetArticleAsFeatured.class.php new file mode 100644 index 00000000000..3fc176af3dd --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/SetArticleAsFeatured.class.php @@ -0,0 +1,45 @@ + + * @since 6.3 + */ +#[PostRequest('/core/articles/{id:\d+}/set-as-featured')] +final class SetArticleAsFeatured implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + if (\MODULE_ARTICLE === 0) { + throw new IllegalLinkException(); + } + + if (!Article::canFeature()) { + throw new PermissionDeniedException(); + } + + $article = Helper::fetchObjectFromRequestParameter($variables['id'], Article::class); + + if ($article->isFeatured === 0) { + new \wcf\command\article\SetArticleAsFeatured($article)(); + } + + return new JsonResponse([]); + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/UnsetArticleAsFeatured.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/UnsetArticleAsFeatured.class.php new file mode 100644 index 00000000000..58cf7343973 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/UnsetArticleAsFeatured.class.php @@ -0,0 +1,45 @@ + + * @since 6.3 + */ +#[PostRequest('/core/articles/{id:\d+}/unset-as-featured')] +final class UnsetArticleAsFeatured implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + if (\MODULE_ARTICLE === 0) { + throw new IllegalLinkException(); + } + + if (!Article::canFeature()) { + throw new PermissionDeniedException(); + } + + $article = Helper::fetchObjectFromRequestParameter($variables['id'], Article::class); + + if ($article->isFeatured !== 0) { + new \wcf\command\article\UnsetArticleAsFeatured($article)(); + } + + return new JsonResponse([]); + } +} diff --git a/wcfsetup/install/files/lib/system/gridView/admin/ArticleGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/ArticleGridView.class.php index 2a2a3fca163..09afb8c2d67 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/ArticleGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/ArticleGridView.class.php @@ -144,6 +144,15 @@ public function render(mixed $value, DatabaseObject $row): string WCF::getLanguage()->get('wcf.acp.article.publicationStatus.delayed') ); } + if ($row->isFeatured !== 0) { + if ($badges !== '') { + $badges .= ' '; + } + $badges .= \sprintf( + '%s', + WCF::getLanguage()->get('wcf.article.featured') + ); + } // @phpstan-ignore property.notFound $articleTitle = StringUtil::encodeHTML($row->title); @@ -203,6 +212,7 @@ public function applyFilter(DatabaseObjectList $list, string $value): void 'wcf.acp.article.publicationStatus' ), new BooleanFilter('isDeleted', 'wcf.acp.article.isDeleted'), + new BooleanFilter('isFeatured', 'wcf.article.featured'), ]); $provider = new ArticleInteractions(); $provider->addInteractions([ diff --git a/wcfsetup/install/files/lib/system/interaction/admin/ArticleInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/ArticleInteractions.class.php index 0c907b48adb..33b7cc07b11 100644 --- a/wcfsetup/install/files/lib/system/interaction/admin/ArticleInteractions.class.php +++ b/wcfsetup/install/files/lib/system/interaction/admin/ArticleInteractions.class.php @@ -89,6 +89,30 @@ public function __construct() return $article->publicationStatus === Article::PUBLISHED; } ), + new RpcInteraction( + 'set-as-featured', + 'core/articles/%s/set-as-featured', + 'wcf.article.button.setAsFeatured', + isAvailableCallback: static function (Article $article): bool { + if (!Article::canFeature()) { + return false; + } + + return $article->isFeatured === 0; + } + ), + new RpcInteraction( + 'unset-as-featured', + 'core/articles/%s/unset-as-featured', + 'wcf.article.button.unsetAsFeatured', + isAvailableCallback: static function (Article $article): bool { + if (!Article::canFeature()) { + return false; + } + + return $article->isFeatured !== 0; + } + ), new FormBuilderDialogInteraction( 'toggle-i18n', LinkHandler::getInstance()->getControllerLink( diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/admin/ArticleBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/admin/ArticleBulkInteractions.class.php index 449597d4857..a2d89a04271 100644 --- a/wcfsetup/install/files/lib/system/interaction/bulk/admin/ArticleBulkInteractions.class.php +++ b/wcfsetup/install/files/lib/system/interaction/bulk/admin/ArticleBulkInteractions.class.php @@ -89,6 +89,34 @@ function (Article $article): bool { return $article->publicationStatus === Article::PUBLISHED; } ), + new BulkRpcInteraction( + 'set-as-featured', + 'core/articles/%s/set-as-featured', + 'wcf.article.button.setAsFeatured', + InteractionConfirmationType::None, + '', + function (Article $article): bool { + if (!Article::canFeature()) { + return false; + } + + return $article->isFeatured === 0; + } + ), + new BulkRpcInteraction( + 'unset-as-featured', + 'core/articles/%s/unset-as-featured', + 'wcf.article.button.unsetAsFeatured', + InteractionConfirmationType::None, + '', + function (Article $article): bool { + if (!Article::canFeature()) { + return false; + } + + return $article->isFeatured !== 0; + } + ), new BulkFormBuilderDialogInteraction( 'setCategory', ArticleCategoryAction::class, diff --git a/wcfsetup/install/files/lib/system/interaction/bulk/user/ArticleBulkInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/bulk/user/ArticleBulkInteractions.class.php index 68462185740..ecaf211daa6 100644 --- a/wcfsetup/install/files/lib/system/interaction/bulk/user/ArticleBulkInteractions.class.php +++ b/wcfsetup/install/files/lib/system/interaction/bulk/user/ArticleBulkInteractions.class.php @@ -85,6 +85,34 @@ function (Article $article): bool { return $article->publicationStatus === Article::PUBLISHED; } ), + new BulkRpcInteraction( + 'set-as-featured', + 'core/articles/%s/set-as-featured', + 'wcf.article.button.setAsFeatured', + InteractionConfirmationType::None, + '', + function (Article $article): bool { + if (!Article::canFeature()) { + return false; + } + + return $article->isFeatured === 0; + } + ), + new BulkRpcInteraction( + 'unset-as-featured', + 'core/articles/%s/unset-as-featured', + 'wcf.article.button.unsetAsFeatured', + InteractionConfirmationType::None, + '', + function (Article $article): bool { + if (!Article::canFeature()) { + return false; + } + + return $article->isFeatured !== 0; + } + ), ] ); } diff --git a/wcfsetup/install/files/lib/system/interaction/user/ArticleInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/user/ArticleInteractions.class.php index a6a0787590b..77a568105c4 100644 --- a/wcfsetup/install/files/lib/system/interaction/user/ArticleInteractions.class.php +++ b/wcfsetup/install/files/lib/system/interaction/user/ArticleInteractions.class.php @@ -76,6 +76,30 @@ public function __construct() return $article->publicationStatus === Article::PUBLISHED; } ), + new RpcInteraction( + 'set-as-featured', + 'core/articles/%s/set-as-featured', + 'wcf.article.button.setAsFeatured', + isAvailableCallback: static function (Article $article): bool { + if (!Article::canFeature()) { + return false; + } + + return $article->isFeatured === 0; + } + ), + new RpcInteraction( + 'unset-as-featured', + 'core/articles/%s/unset-as-featured', + 'wcf.article.button.unsetAsFeatured', + isAvailableCallback: static function (Article $article): bool { + if (!Article::canFeature()) { + return false; + } + + return $article->isFeatured !== 0; + } + ), new Divider(), new EditInteraction(ArticleEditForm::class, function (Article $article): bool { return $article->canEdit(); diff --git a/wcfsetup/install/files/lib/system/listView/user/ArticleListView.class.php b/wcfsetup/install/files/lib/system/listView/user/ArticleListView.class.php index 6f13413cae2..a4d961e0bc2 100644 --- a/wcfsetup/install/files/lib/system/listView/user/ArticleListView.class.php +++ b/wcfsetup/install/files/lib/system/listView/user/ArticleListView.class.php @@ -32,6 +32,8 @@ */ class ArticleListView extends AbstractListView { + protected bool $pinFeaturedArticles = true; + public function __construct() { $this->addAvailableSortFields([ @@ -194,6 +196,26 @@ protected function getInitializedEvent(): ArticleListViewInitialized return new ArticleListViewInitialized($this); } + #[\Override] + protected function getSqlOrderBy(): string + { + if (!$this->pinFeaturedArticles) { + return parent::getSqlOrderBy(); + } + + return 'article.isFeatured DESC, ' . parent::getSqlOrderBy(); + } + + /** + * Sets whether featured articles are pinned at the top of the list. + * + * @since 6.3 + */ + public function setPinFeaturedArticles(bool $pinFeaturedArticles): void + { + $this->pinFeaturedArticles = $pinFeaturedArticles; + } + public function canMarkAsRead(): bool { if (WCF::getUser()->isGuest()) { diff --git a/wcfsetup/install/files/lib/system/listView/user/RelatedArticleListView.class.php b/wcfsetup/install/files/lib/system/listView/user/RelatedArticleListView.class.php index 9c7f375e27a..5841038b343 100644 --- a/wcfsetup/install/files/lib/system/listView/user/RelatedArticleListView.class.php +++ b/wcfsetup/install/files/lib/system/listView/user/RelatedArticleListView.class.php @@ -26,6 +26,7 @@ public function __construct(public readonly int $articleContentID) $this->setAllowFiltering(false); $this->setAllowSorting(false); $this->setAllowInteractions(false); + $this->setPinFeaturedArticles(false); } #[\Override] diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 24b46c56312..1515b193a1b 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -3315,6 +3315,10 @@ freigeschaltet. {if LANGUAGE_USE_INFORMAL_VARIANT}Du kannst{else}Sie können{/if + + + + username}]]> diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 39b791f73fc..02808901451 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -3240,6 +3240,10 @@ Your account on {@PAGE_TITLE|phrase} [URL:{link isEmail=true}{/link}] has been a + + + + username}]]> diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 257fef0c77e..a873083a91b 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -435,6 +435,7 @@ ->defaultValue(0), DefaultFalseBooleanDatabaseTableColumn::create('isDeleted'), DefaultFalseBooleanDatabaseTableColumn::create('hasLabels'), + DefaultFalseBooleanDatabaseTableColumn::create('isFeatured'), ]) ->indices([ DatabaseTablePrimaryIndex::create()