Bookmarks: Add paging.

Change-Id: Icd53dd04a74b94e1fa80f23703348070d598c413
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
index 1c6653a..dc45f34 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/bookmarks/BookmarkResource.java
@@ -29,9 +29,11 @@
 import java.time.format.DateTimeFormatter;
 import java.time.format.FormatStyle;
 import java.time.temporal.TemporalAccessor;
+import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.Date;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 import javax.annotation.CheckForNull;
@@ -41,9 +43,11 @@
 import javax.json.spi.JsonProvider;
 import javax.persistence.EntityManager;
 import javax.persistence.PersistenceContext;
+import javax.persistence.criteria.CriteriaBuilder;
 import javax.persistence.criteria.CriteriaQuery;
 import javax.persistence.criteria.From;
 import javax.persistence.criteria.JoinType;
+import javax.persistence.criteria.Predicate;
 import javax.transaction.Transactional;
 import javax.validation.constraints.NotEmpty;
 import javax.validation.constraints.NotNull;
@@ -76,6 +80,9 @@
 
   private static JsonProvider jsonProvider = JsonProvider.provider();
 
+  @ConfigProperty(name = "mulkcms.bookmarks.default-max-results")
+  int defaultMaxResults;
+
   @ResourcePath("benki/bookmarks/bookmarkList.html")
   @Inject
   Template bookmarkList;
@@ -96,24 +103,47 @@
 
   @GET
   @Produces(TEXT_HTML)
-  public TemplateInstance getIndex() {
-    var bookmarkQuery = selectBookmarks(null);
+  public TemplateInstance getIndex(
+      @QueryParam("i") @CheckForNull Integer cursor,
+      @QueryParam("n") @CheckForNull Integer maxResults) {
+
+    maxResults = maxResults == null ? defaultMaxResults : maxResults;
+
+    var q = selectBookmarks(null, cursor, maxResults);
+
     return bookmarkList
-        .data("bookmarks", bookmarkQuery)
+        .data("bookmarks", q.bookmarks)
         .data("feedUri", "/bookmarks/feed")
-        .data("authenticated", !identity.isAnonymous());
+        .data("authenticated", !identity.isAnonymous())
+        .data("hasPreviousPage", q.prevCursor != null)
+        .data("hasNextPage", q.nextCursor != null)
+        .data("previousCursor", q.prevCursor)
+        .data("nextCursor", q.nextCursor)
+        .data("pageSize", maxResults);
   }
 
   @GET
   @Path("~{ownerName}")
   @Produces(TEXT_HTML)
-  public TemplateInstance getUserIndex(@PathParam("ownerName") String ownerName) {
+  public TemplateInstance getUserIndex(
+      @PathParam("ownerName") String ownerName,
+      @QueryParam("i") @CheckForNull Integer cursor,
+      @QueryParam("n") @CheckForNull Integer maxResults) {
+
+    maxResults = maxResults == null ? defaultMaxResults : maxResults;
+
     var owner = User.findByNickname(ownerName);
-    var bookmarkQuery = selectBookmarks(owner);
+    var q = selectBookmarks(owner, cursor, maxResults);
+
     return bookmarkList
-        .data("bookmarks", bookmarkQuery)
+        .data("bookmarks", q.bookmarks)
         .data("feedUri", String.format("/bookmarks/~%s/feed", ownerName))
-        .data("authenticated", !identity.isAnonymous());
+        .data("authenticated", !identity.isAnonymous())
+        .data("hasPreviousPage", q.prevCursor != null)
+        .data("hasNextPage", q.nextCursor != null)
+        .data("previousCursor", q.prevCursor)
+        .data("nextCursor", q.nextCursor)
+        .data("pageSize", maxResults);
   }
 
   @GET
@@ -267,35 +297,114 @@
     return htmlDateFormatter.format(x);
   }
 
-  private List<Bookmark> selectBookmarks(@Nullable User owner) {
+  private static class BookmarkPage {
+    @CheckForNull Integer prevCursor;
+    @CheckForNull Integer cursor;
+    @CheckForNull Integer nextCursor;
+    List<Bookmark> bookmarks;
+
+    public BookmarkPage(
+        @CheckForNull Integer c0,
+        @CheckForNull Integer c1,
+        @CheckForNull Integer c2,
+        List<Bookmark> resultList) {
+      this.prevCursor = c0;
+      this.cursor = c1;
+      this.nextCursor = c2;
+      this.bookmarks = resultList;
+    }
+  }
+
+  private List<Bookmark> selectBookmarks(@CheckForNull User owner) {
+    return selectBookmarks(owner, null, null).bookmarks;
+  }
+
+  private BookmarkPage selectBookmarks(
+      @CheckForNull User owner, @CheckForNull Integer cursor, @CheckForNull Integer count) {
+
+    if (cursor != null) {
+      Objects.requireNonNull(count);
+    }
+
     var cb = entityManager.unwrap(Session.class).getCriteriaBuilder();
 
+    var forwardCriteria = generateBookmarkCriteriaQuery(owner, cursor, cb, true);
+    var forwardQuery = entityManager.createQuery(forwardCriteria);
+
+    if (count != null) {
+      forwardQuery.setMaxResults(count + 1);
+    }
+
+    log.debug(forwardQuery.unwrap(org.hibernate.query.Query.class).getQueryString());
+
+    @CheckForNull Integer prevCursor = null;
+    @CheckForNull Integer nextCursor = null;
+
+    if (cursor != null) {
+      // Look backwards as well so we can find the prevCursor.
+      var backwardCriteria = generateBookmarkCriteriaQuery(owner, cursor, cb, false);
+      var backwardQuery = entityManager.createQuery(backwardCriteria);
+      backwardQuery.setMaxResults(count);
+      var backwardResults = backwardQuery.getResultList();
+      if (!backwardResults.isEmpty()) {
+        prevCursor = backwardResults.get(backwardResults.size() - 1).id;
+      }
+    }
+
+    var forwardResults = forwardQuery.getResultList();
+    if (count != null) {
+      if (forwardResults.size() == count + 1) {
+        nextCursor = forwardResults.get(count).id;
+        forwardResults.remove((int) count);
+      }
+    }
+
+    return new BookmarkPage(prevCursor, cursor, nextCursor, forwardResults);
+  }
+
+  private CriteriaQuery<Bookmark> generateBookmarkCriteriaQuery(
+      @CheckForNull User owner, @CheckForNull Integer cursor, CriteriaBuilder cb, boolean forward) {
     CriteriaQuery<Bookmark> query = cb.createQuery(Bookmark.class);
 
+    var conditions = new ArrayList<Predicate>();
+
     From<?, Bookmark> bm;
     if (identity.isAnonymous()) {
       bm = query.from(Bookmark.class);
       var target = bm.join(Bookmark_.targets);
-      query.where(cb.equal(target, Role.getWorld()));
+      conditions.add(cb.equal(target, Role.getWorld()));
     } else {
       var userName = identity.getPrincipal().getName();
       var user = User.findByNickname(userName);
 
       var root = query.from(User.class);
-      query.where(cb.equal(root, user));
+      conditions.add(cb.equal(root, user));
       bm = root.join(User_.visibleBookmarks);
     }
 
     query.select(bm);
     bm.fetch(Bookmark_.owner, JoinType.LEFT);
-    query.orderBy(cb.desc(bm.get(Bookmark_.date)));
 
     if (owner != null) {
-      query.where(cb.equal(bm.get(Bookmark_.owner), owner));
+      conditions.add(cb.equal(bm.get(Bookmark_.owner), owner));
     }
 
-    var q = entityManager.createQuery(query);
-    log.debug(q.unwrap(org.hibernate.query.Query.class).getQueryString());
-    return q.getResultList();
+    if (forward) {
+      query.orderBy(cb.desc(bm.get(Bookmark_.id)));
+    } else {
+      query.orderBy(cb.asc(bm.get(Bookmark_.id)));
+    }
+
+    if (cursor != null) {
+      if (forward) {
+        conditions.add(cb.le(bm.get(Bookmark_.id), cursor));
+      } else {
+        conditions.add(cb.gt(bm.get(Bookmark_.id), cursor));
+      }
+    }
+
+    query.where(conditions.toArray(new Predicate[0]));
+
+    return query;
   }
 }
diff --git a/src/main/resources/META-INF/resources/cms2/base.css b/src/main/resources/META-INF/resources/cms2/base.css
index ec84ca9..61f447c 100644
--- a/src/main/resources/META-INF/resources/cms2/base.css
+++ b/src/main/resources/META-INF/resources/cms2/base.css
@@ -124,7 +124,18 @@
   background-color: var(--main-bg-color);
   padding: 10px;
   border-left: 1px solid lightgray;
-  overflow: scroll;
+  overflow: auto;
+
+  display: flex;
+  flex-direction: column;
+}
+
+main > * {
+  margin-top: 0.5rem;
+}
+
+main > *:first-child {
+  margin-top: 0;
 }
 
 body > footer {
@@ -175,3 +186,24 @@
 #bookmark-submission textarea {
   min-width: calc(100% - 12em);
 }
+
+.paging {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap-reverse;
+}
+
+.paging > .filler {
+  flex: 1;
+}
+
+.paging > a {
+  flex-grow: 0;
+  flex-shrink: 1;
+  flex-basis: content;
+}
+
+elix-expandable-section .expandable-section-title {
+  margin-top: 0;
+  margin-bottom: 0;
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 50423c0..b90cc9e 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -8,6 +8,7 @@
 #quarkus.log.category."io.vertx.ext.jwt".level = FINEST
 
 mulkcms.tag-base = hub.benkard.de
+mulkcms.bookmarks.default-max-results = 25
 
 quarkus.datasource.driver = org.postgresql.Driver
 quarkus.datasource.max-size = 8
diff --git a/src/main/resources/templates/benki/bookmarks/bookmarkList.html b/src/main/resources/templates/benki/bookmarks/bookmarkList.html
index 9b5025c..290cb26 100644
--- a/src/main/resources/templates/benki/bookmarks/bookmarkList.html
+++ b/src/main/resources/templates/benki/bookmarks/bookmarkList.html
@@ -1,5 +1,10 @@
 {@java.util.List<eu.mulk.mulkcms2.benki.bookmarks.Bookmark> bookmarks}
 {@java.lang.Boolean authenticated}
+{@java.lang.Boolean hasPreviousPage}
+{@java.lang.Boolean hasNextPage}
+{@java.lang.Integer previousCursor}
+{@java.lang.Integer nextCursor}
+{@java.lang.Integer pageSize}
 
 {#include base.html}
 
@@ -9,40 +14,54 @@
 
 {#head}
   <link href="{feedUri}" rel="alternate" type="application/atom+xml" />
+
+  <script type="module" src="/web_modules/elix/define/ExpandableSection.js"></script>
+  <script type="module" src="/bookmarks/MlkBookmarkSubmissionForm.js"></script>
+  <script type="module" src="/bookmarks/bookmarkList.js" defer></script>
 {/head}
 
 {#body}
 
 {! #if authenticated !}
-  <script type="module" src="/web_modules/elix/define/ExpandableSection.js"></script>
-  <script type="module" src="/bookmarks/MlkBookmarkSubmissionForm.js"></script>
-  <script type="module" src="/bookmarks/bookmarkList.js" defer></script>
-
   <elix-expandable-section id="bookmark-submission-pane">
-    <h2 slot="header" class="small-title"><button class="pure-button">Create New Bookmark</button></h2>
+    <h2 slot="header" class="small-title expandable-section-title"><button class="pure-button">Create New Bookmark</button></h2>
     <section id="bookmark-submission">
       <mlk-bookmark-submission-form id="bookmark-submission-form"></mlk-bookmark-submission-form>
     </section>
   </elix-expandable-section>
 {! /if !}
 
-{#for bookmark in bookmarks}
-  {#with bookmark}
-    <article class="bookmark">
-      <header>
-        <a href="{uri}"><h1 class="bookmark-title">{title}</h1></a>
-        <div class="bookmark-info">
-          <time datetime="{date.htmlDateTime}">{date.humanDateTime}</time>
-          <span class="bookmark-owner">{owner.firstName} {owner.lastName}</span>
-        </div>
-      </header>
+<div class="paging">
+  {#if hasPreviousPage}<a href="?i={previousCursor}&n={pageSize}" class="pure-button">⇠ previous page</a>{/if}
+  <span class="filler"></span>
+  {#if hasNextPage}<a href="?i={nextCursor}&n={pageSize}" class="pure-button">next page ⇢</a>{/if}
+</div>
 
-      <section class="bookmark-description">
-        {descriptionHtml.raw}
-      </section>
-    </article>
-  {/with}
-{/for}
+<section id="main-content">
+  {#for bookmark in bookmarks}
+    {#with bookmark}
+      <article class="bookmark">
+        <header>
+          <a href="{uri}"><h1 class="bookmark-title">{title}</h1></a>
+          <div class="bookmark-info">
+            <time datetime="{date.htmlDateTime}">{date.humanDateTime}</time>
+            <span class="bookmark-owner">{owner.firstName} {owner.lastName}</span>
+          </div>
+        </header>
+
+        <section class="bookmark-description">
+          {descriptionHtml.raw}
+        </section>
+      </article>
+    {/with}
+  {/for}
+</section>
+
+<div class="paging">
+  {#if hasPreviousPage}<a href="?i={previousCursor}&n={pageSize}" class="pure-button">⇠ previous page</a>{/if}
+  <span class="filler"></span>
+  {#if hasNextPage}<a href="?i={nextCursor}&n={pageSize}" class="pure-button">next page ⇢</a>{/if}
+</div>
 
 {/body}