Popover for CMS articles
authorMarcel Werk <burntime@woltlab.com>
Fri, 3 Jan 2025 15:14:18 +0000 (16:14 +0100)
committerMarcel Werk <burntime@woltlab.com>
Fri, 3 Jan 2025 15:14:18 +0000 (16:14 +0100)
com.woltlab.wcf/templates/article.tpl
com.woltlab.wcf/templates/articlePopover.tpl [new file with mode: 0644]
com.woltlab.wcf/templates/boxArticleList.tpl
ts/WoltLabSuite/Core/Api/Articles/GetArticlePopover.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/BootstrapFrontend.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Api/Articles/GetArticlePopover.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js
wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
wcfsetup/install/files/lib/data/article/Article.class.php
wcfsetup/install/files/lib/system/endpoint/controller/core/articles/GetArticlePopover.class.php [new file with mode: 0644]

index eed09e2e4e21176cc3dd9b5ac0794ea0fbbad430..6373f9deb1e28b2968b129c53b1bb21b89628245 100644 (file)
                                                <span class="articleNavigationArticleContent">
                                                        <span class="articleNavigationEntityName">{lang}wcf.article.previousArticle{/lang}</span>
                                                        <span class="articleNavigationArticleTitle">
-                                                               <a href="{$previousArticle->getLink()}" rel="prev" class="articleNavigationArticleLink">
+                                                               <a href="{$previousArticle->getLink()}" rel="prev" class="articleNavigationArticleLink articleLink" data-object-id="{$previousArticle->getObjectID()}">
                                                                        {$previousArticle->getTitle()}
                                                                </a>
                                                        </span>
                                                <span class="articleNavigationArticleContent">
                                                        <span class="articleNavigationEntityName">{lang}wcf.article.nextArticle{/lang}</span>
                                                        <span class="articleNavigationArticleTitle">
-                                                               <a href="{$nextArticle->getLink()}" rel="next" class="articleNavigationArticleLink">
+                                                               <a href="{$nextArticle->getLink()}" rel="next" class="articleNavigationArticleLink articleLink" data-object-id="{$nextArticle->getObjectID()}">
                                                                        {$nextArticle->getTitle()}
                                                                </a>
                                                        </span>
diff --git a/com.woltlab.wcf/templates/articlePopover.tpl b/com.woltlab.wcf/templates/articlePopover.tpl
new file mode 100644 (file)
index 0000000..864f2bc
--- /dev/null
@@ -0,0 +1,31 @@
+<div class="popover__layout">
+       {if $article->getTeaserImage()}
+               <div class="popover__coverPhoto">
+                       <img class="popover__coverPhoto__image" src="{$article->getTeaserImage()->getThumbnailLink('medium')}" alt="">
+               </div>
+       {/if}
+       
+       <div class="popover__header">
+               {event name='beforeHeader'}
+               
+               <div class="popover__avatar">
+                       {user object=$article->getUserProfile() type='avatar48' ariaHidden='true' tabindex='-1'}
+               </div>
+               <div class="popover__title">
+                       <a href="{$article->getLink()}">{$article->getTitle()}</a>
+               </div>
+               <div class="popover__time">
+                       {time time=$article->time}
+               </div>
+
+               {event name='afterHeader'}
+       </div>
+
+       {event name='beforeText'}
+
+       <div class="popover__text htmlContent">
+               {unsafe:$article->getFormattedTeaser()}
+       </div>
+
+       {event name='afterText'}
+</div>
index 0dcd50e506cde8484b63f9009c290cece7f681e9..e82b06fe6ed10a29aeb968190510e1b46fde691f 100644 (file)
@@ -5,7 +5,7 @@
                                <a href="{$boxArticle->getLink()}" aria-hidden="true" tabindex="-1">{unsafe:$boxArticle->getUserProfile()->getAvatar()->getImageTag(24)}</a>
                                
                                <div class="sidebarItemTitle">
-                                       <h3><a href="{$boxArticle->getLink()}">{$boxArticle->getTitle()}</a></h3>
+                                       <h3>{anchor object=$boxArticle class='articleLink' title=$boxArticle->getTitle()}</h3>
                                        
                                        <small>
                                                {if $boxSortField == 'time'}
diff --git a/ts/WoltLabSuite/Core/Api/Articles/GetArticlePopover.ts b/ts/WoltLabSuite/Core/Api/Articles/GetArticlePopover.ts
new file mode 100644 (file)
index 0000000..f261bf5
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * Gets the html code for the rendering of an article popover.
+ *
+ * @author  Marcel Werk
+ * @copyright  2001-2025 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.2
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "WoltLabSuite/Core/Api/Result";
+
+type Response = {
+  template: string;
+};
+
+export async function getArticlePopover(articleId: number): Promise<ApiResult<string>> {
+  const url = new URL(`${window.WSC_RPC_API_URL}core/articles/${articleId}/popover`);
+
+  let response: Response;
+  try {
+    response = (await prepareRequest(url).get().fetchAsJson()) as Response;
+  } catch (e) {
+    return apiResultFromError(e);
+  }
+
+  return apiResultFromValue(response.template);
+}
index bf3f2b9c14bcf3802e8d5700677ba08ef5f17b75..efc594736f83931ece44280b19e6d0b8549c7912 100644 (file)
@@ -19,6 +19,7 @@ import UiPageMenuMainFrontend from "./Ui/Page/Menu/Main/Frontend";
 import { whenFirstSeen } from "./LazyLoader";
 import { prepareRequest } from "./Ajax/Backend";
 import { setup as serviceWorkerSetup } from "./Notification/ServiceWorker";
+import { getArticlePopover } from "./Api/Articles/GetArticlePopover";
 
 interface BootstrapOptions {
   backgroundQueue: {
@@ -57,6 +58,20 @@ function setupUserPopover(endpoint: string): void {
   });
 }
 
+function setupArticlePopover(): void {
+  whenFirstSeen(".articleLink", () => {
+    void import("WoltLabSuite/Core/Component/Popover").then(({ setupFor }) => {
+      setupFor({
+        endpoint: async (objectId: number) => {
+          return (await getArticlePopover(objectId)).unwrap();
+        },
+        identifier: "com.woltlab.wcf.article",
+        selector: ".articleLink",
+      });
+    });
+  });
+}
+
 declare const COMPILER_TARGET_DEFAULT: boolean;
 
 /**
@@ -80,6 +95,7 @@ export function setup(options: BootstrapOptions): void {
   }
 
   setupUserPopover(options.endpointUserPopover);
+  setupArticlePopover();
 
   if (options.executeCronjobs !== undefined) {
     void prepareRequest(options.executeCronjobs)
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Articles/GetArticlePopover.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Articles/GetArticlePopover.js
new file mode 100644 (file)
index 0000000..e359df9
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Gets the html code for the rendering of an article popover.
+ *
+ * @author  Marcel Werk
+ * @copyright  2001-2025 WoltLab GmbH
+ * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.2
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "WoltLabSuite/Core/Api/Result"], function (require, exports, Backend_1, Result_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.getArticlePopover = getArticlePopover;
+    async function getArticlePopover(articleId) {
+        const url = new URL(`${window.WSC_RPC_API_URL}core/articles/${articleId}/popover`);
+        let response;
+        try {
+            response = (await (0, Backend_1.prepareRequest)(url).get().fetchAsJson());
+        }
+        catch (e) {
+            return (0, Result_1.apiResultFromError)(e);
+        }
+        return (0, Result_1.apiResultFromValue)(response.template);
+    }
+});
index 4ef44badd6af590972cb4e99333a30c6d6dff78e..5188e74aa2e63b1735003bc2c99503bcdd22764b 100644 (file)
@@ -5,7 +5,7 @@
  * @copyright  2001-2019 WoltLab GmbH
  * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
  */
-define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui/User/Ignore", "./Ui/Page/Header/Menu", "./Ui/Message/UserConsent", "./Ui/Message/Share/Dialog", "./Ui/Message/Share/Providers", "./Ui/Feed/Dialog", "./User", "./Ui/Page/Menu/Main/Frontend", "./LazyLoader", "./Ajax/Backend", "./Notification/ServiceWorker"], function (require, exports, tslib_1, BackgroundQueue, Bootstrap, UiUserIgnore, UiPageHeaderMenu, UiMessageUserConsent, UiMessageShareDialog, Providers_1, UiFeedDialog, User_1, Frontend_1, LazyLoader_1, Backend_1, ServiceWorker_1) {
+define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui/User/Ignore", "./Ui/Page/Header/Menu", "./Ui/Message/UserConsent", "./Ui/Message/Share/Dialog", "./Ui/Message/Share/Providers", "./Ui/Feed/Dialog", "./User", "./Ui/Page/Menu/Main/Frontend", "./LazyLoader", "./Ajax/Backend", "./Notification/ServiceWorker", "./Api/Articles/GetArticlePopover"], function (require, exports, tslib_1, BackgroundQueue, Bootstrap, UiUserIgnore, UiPageHeaderMenu, UiMessageUserConsent, UiMessageShareDialog, Providers_1, UiFeedDialog, User_1, Frontend_1, LazyLoader_1, Backend_1, ServiceWorker_1, GetArticlePopover_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.setup = setup;
@@ -35,6 +35,19 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui
             });
         });
     }
+    function setupArticlePopover() {
+        (0, LazyLoader_1.whenFirstSeen)(".articleLink", () => {
+            void new Promise((resolve_2, reject_2) => { require(["WoltLabSuite/Core/Component/Popover"], resolve_2, reject_2); }).then(tslib_1.__importStar).then(({ setupFor }) => {
+                setupFor({
+                    endpoint: async (objectId) => {
+                        return (await (0, GetArticlePopover_1.getArticlePopover)(objectId)).unwrap();
+                    },
+                    identifier: "com.woltlab.wcf.article",
+                    selector: ".articleLink",
+                });
+            });
+        });
+    }
     /**
      * Bootstraps general modules and frontend exclusive ones.
      */
@@ -48,11 +61,12 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui
         });
         UiPageHeaderMenu.init();
         if (options.styleChanger) {
-            void new Promise((resolve_2, reject_2) => { require(["./Controller/Style/Changer"], resolve_2, reject_2); }).then(tslib_1.__importStar).then((ControllerStyleChanger) => {
+            void new Promise((resolve_3, reject_3) => { require(["./Controller/Style/Changer"], resolve_3, reject_3); }).then(tslib_1.__importStar).then((ControllerStyleChanger) => {
                 ControllerStyleChanger.setup();
             });
         }
         setupUserPopover(options.endpointUserPopover);
+        setupArticlePopover();
         if (options.executeCronjobs !== undefined) {
             void (0, Backend_1.prepareRequest)(options.executeCronjobs)
                 .get()
@@ -82,22 +96,22 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui
             }
         }
         (0, LazyLoader_1.whenFirstSeen)("woltlab-core-reaction-summary", () => {
-            void new Promise((resolve_3, reject_3) => { require(["./Ui/Reaction/SummaryDetails"], resolve_3, reject_3); }).then(tslib_1.__importStar).then(({ setup }) => setup());
+            void new Promise((resolve_4, reject_4) => { require(["./Ui/Reaction/SummaryDetails"], resolve_4, reject_4); }).then(tslib_1.__importStar).then(({ setup }) => setup());
         });
         (0, LazyLoader_1.whenFirstSeen)("woltlab-core-comment", () => {
-            void new Promise((resolve_4, reject_4) => { require(["./Component/Comment/woltlab-core-comment"], resolve_4, reject_4); }).then(tslib_1.__importStar);
+            void new Promise((resolve_5, reject_5) => { require(["./Component/Comment/woltlab-core-comment"], resolve_5, reject_5); }).then(tslib_1.__importStar);
         });
         (0, LazyLoader_1.whenFirstSeen)("woltlab-core-comment-response", () => {
-            void new Promise((resolve_5, reject_5) => { require(["./Component/Comment/Response/woltlab-core-comment-response"], resolve_5, reject_5); }).then(tslib_1.__importStar);
+            void new Promise((resolve_6, reject_6) => { require(["./Component/Comment/Response/woltlab-core-comment-response"], resolve_6, reject_6); }).then(tslib_1.__importStar);
         });
         (0, LazyLoader_1.whenFirstSeen)("woltlab-core-emoji-picker", () => {
-            void new Promise((resolve_6, reject_6) => { require(["./Component/EmojiPicker/woltlab-core-emoji-picker"], resolve_6, reject_6); }).then(tslib_1.__importStar);
+            void new Promise((resolve_7, reject_7) => { require(["./Component/EmojiPicker/woltlab-core-emoji-picker"], resolve_7, reject_7); }).then(tslib_1.__importStar);
         });
         (0, LazyLoader_1.whenFirstSeen)("[data-follow-user]", () => {
-            void new Promise((resolve_7, reject_7) => { require(["./Component/User/Follow"], resolve_7, reject_7); }).then(tslib_1.__importStar).then(({ setup }) => setup());
+            void new Promise((resolve_8, reject_8) => { require(["./Component/User/Follow"], resolve_8, reject_8); }).then(tslib_1.__importStar).then(({ setup }) => setup());
         });
         (0, LazyLoader_1.whenFirstSeen)("[data-ignore-user]", () => {
-            void new Promise((resolve_8, reject_8) => { require(["./Component/User/Ignore"], resolve_8, reject_8); }).then(tslib_1.__importStar).then(({ setup }) => setup());
+            void new Promise((resolve_9, reject_9) => { require(["./Component/User/Ignore"], resolve_9, reject_9); }).then(tslib_1.__importStar).then(({ setup }) => setup());
         });
     }
 });
index 6bdd453fef1c68e544ea43e33f2f5842a46ae999..47f82dfd4081d9b776261eb17327c1e1d75fcf59 100644 (file)
@@ -116,6 +116,7 @@ return static function (): void {
     $eventHandler->register(
         \wcf\event\endpoint\ControllerCollecting::class,
         static function (\wcf\event\endpoint\ControllerCollecting $event) {
+            $event->register(new \wcf\system\endpoint\controller\core\articles\GetArticlePopover);
             $event->register(new \wcf\system\endpoint\controller\core\files\DeleteFile);
             $event->register(new \wcf\system\endpoint\controller\core\files\GenerateThumbnails);
             $event->register(new \wcf\system\endpoint\controller\core\files\PrepareUpload);
index 93de809aac2c117e35b4168cc2e34a763092e095..5df3fb1dae7fd03d5551e4e51a2c1a13613650a0 100644 (file)
@@ -7,6 +7,7 @@ use wcf\data\article\content\ArticleContent;
 use wcf\data\attachment\GroupedAttachmentList;
 use wcf\data\DatabaseObject;
 use wcf\data\ILinkableObject;
+use wcf\data\IPopoverObject;
 use wcf\data\IUserContent;
 use wcf\data\object\type\ObjectTypeCache;
 use wcf\data\user\UserProfile;
@@ -38,7 +39,7 @@ use wcf\system\WCF;
  * @property-read   int $isDeleted      is 1 if the article is in trash bin, otherwise 0
  * @property-read   int $hasLabels      is `1` if labels are assigned to the article
  */
-class Article extends DatabaseObject implements ILinkableObject, IUserContent
+class Article extends DatabaseObject implements ILinkableObject, IPopoverObject, IUserContent
 {
     /**
      * indicates that article is unpublished
@@ -443,4 +444,10 @@ class Article extends DatabaseObject implements ILinkableObject, IUserContent
 
         return null;
     }
+
+    #[\Override]
+    public function getPopoverLinkClass()
+    {
+        return 'articleLink';
+    }
 }
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/GetArticlePopover.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/GetArticlePopover.class.php
new file mode 100644 (file)
index 0000000..05c0e7e
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+namespace wcf\system\endpoint\controller\core\articles;
+
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use wcf\data\article\Article;
+use wcf\data\article\ViewableArticle;
+use wcf\http\Helper;
+use wcf\system\endpoint\GetRequest;
+use wcf\system\endpoint\IController;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\WCF;
+
+/**
+ * API endpoint for the rendering of the article popover.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2025 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+#[GetRequest('/core/articles/{id:\d+}/popover')]
+final class GetArticlePopover implements IController
+{
+    #[\Override]
+    public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+    {
+        $article = Helper::fetchObjectFromRequestParameter($variables['id'], Article::class);
+
+        $this->assertArticleIsAccessible($article);
+
+        return new JsonResponse([
+            'template' => $this->renderPopover($article),
+        ]);
+    }
+
+    private function assertArticleIsAccessible(Article $article): void
+    {
+        if (!$article->canRead()) {
+            throw new PermissionDeniedException();
+        }
+    }
+
+    private function renderPopover(Article $article): string
+    {
+        return WCF::getTPL()->fetch('articlePopover', 'wcf', [
+            'article' => ViewableArticle::getArticle($article->articleID),
+        ], true);
+    }
+}