{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()